Merge PR #500 into CI optimization
This commit is contained in:
382 files changed
+33688
-419
No files matched your search
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService, SessionsService (list store + scope tree + object layer),
|
||||
* the ClientLoader interface, and the cordis Context/Events merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export { SessionManager } from './sessions/manager.ts'
|
||||
export type { SessionListSnapshot } from './sessions/manager.ts'
|
||||
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
|
||||
export type { SessionListEntry } from './sessions/lineage.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
|
||||
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
|
||||
// concrete types live here, where their subjects live) ----
|
||||
|
||||
/**
|
||||
* The client cordis context face: the base Context plus the service keys
|
||||
* this package's declaration merge contributes (slots/sessions/loader) and
|
||||
* every later plugin's merge. A plain alias — the merges land on Context
|
||||
* itself inside the client program; the name marks intent at consumer seams.
|
||||
*/
|
||||
export type ClientContext = Context
|
||||
|
||||
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
|
||||
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
* One tool call as the chat flow renders it: still-running (spinner card) or
|
||||
* settled (result node). The fold produces both shapes; toolview components
|
||||
* narrow on the discriminant fields.
|
||||
*/
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A slot's definition or registration set changed.
|
||||
* @mode emit
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
}
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
loader: ClientLoader
|
||||
}
|
||||
}
|
||||
|
||||
/** One __DSH_BOOT__ manifest row. */
|
||||
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
|
||||
|
||||
/** Per-plugin load status store shape. */
|
||||
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
|
||||
|
||||
/**
|
||||
* Client bundle loader. The immediately group loads first (parallel fetch,
|
||||
* apply in inject topology order); remaining plugins follow in inject
|
||||
* topology. Loaded bundle export surfaces are registered back into the
|
||||
* require module table. Implementation lives in the `./loader` subpath
|
||||
* (shell-held machinery).
|
||||
*/
|
||||
export interface ClientLoader {
|
||||
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
|
||||
start(): void
|
||||
/**
|
||||
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
|
||||
* @param id - plugin id (package name).
|
||||
*/
|
||||
load(id: string): Promise<void>
|
||||
/**
|
||||
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
|
||||
* @param id - plugin id.
|
||||
*/
|
||||
unload(id: string): Promise<void>
|
||||
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
|
||||
settled(): Promise<void>
|
||||
/**
|
||||
* Read a loaded module's export surface from the module table (same
|
||||
* implementation the bundle-facing require uses; unknown spec throws).
|
||||
* @param spec - module specifier (package name or seeded library id).
|
||||
*/
|
||||
requireModule(spec: string): unknown
|
||||
/** Per-plugin status store. */
|
||||
readonly status: SnapshotStore<LoaderStatus>
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount slots + sessions, start the stream loop.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
|
||||
onConnected: () => { sessions.manager.handleConnected() },
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* ClientLoader implementation (shell-held machinery — the loader cannot load
|
||||
* itself, so the web shell imports this subpath statically and mounts the
|
||||
* instance as ctx.loader; the runtime package's own client bundle never
|
||||
* includes it).
|
||||
*
|
||||
* Load chain per plugin: fetch bundle text → execute (script injection) → the
|
||||
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
|
||||
* handoff, id reconciled) → factory(require) with require bound to the module
|
||||
* table → ctx.plugin(exports.apply) → the export surface is registered into
|
||||
* the module table under the plugin id (inject topology guarantees later
|
||||
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
|
||||
*
|
||||
* start(): the `immediately` group is fetched in parallel and executed in
|
||||
* group-internal inject topology (execution is serial — the handoff slot is
|
||||
* single); a full-group barrier precedes the remaining plugins, which then
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — must match the manifest row being loaded. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory: receives the DI require and returns the module's export
|
||||
* surface; an `apply` export is applied as a cordis plugin.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface the loader owns (bundle side of the handoff protocol). */
|
||||
interface DshWindow {
|
||||
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
|
||||
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** Options for createClientLoader (assembled by the web shell at boot). */
|
||||
export interface ClientLoaderOptions {
|
||||
/** Client root context: plugin applies mount under it. */
|
||||
ctx: Context
|
||||
/**
|
||||
* Seeded module table: pure-library entities (react, react-dom, cordis,
|
||||
* ui-slots, web-react, ui-primitives). The loader takes ownership and
|
||||
* registers loaded bundle export surfaces alongside them.
|
||||
*/
|
||||
modules: Record<string, unknown>
|
||||
/**
|
||||
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
|
||||
* same protocol shape.
|
||||
*/
|
||||
boot?: { plugins: BootPluginEntry[] }
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (serial half; execution synchronously performs the
|
||||
* loadPlugin handoff). Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/** Per-plugin bookkeeping across the load chain. */
|
||||
interface PluginRecord {
|
||||
entry: BootPluginEntry
|
||||
state: 'idle' | 'loading' | 'active' | 'failed'
|
||||
fetch?: Promise<string>
|
||||
load?: Promise<void>
|
||||
}
|
||||
|
||||
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
|
||||
|
||||
/**
|
||||
* Build the client bundle loader.
|
||||
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
|
||||
* @returns the ClientLoader the shell mounts as ctx.loader.
|
||||
*/
|
||||
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
|
||||
const { ctx } = options
|
||||
const win = globalThis as DshWindow
|
||||
const boot = options.boot ?? win.__DSH_BOOT__
|
||||
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const modules = new Map<string, unknown>(Object.entries(options.modules))
|
||||
const records = new Map<string, PluginRecord>()
|
||||
for (const entry of boot.plugins) {
|
||||
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
|
||||
records.set(entry.id, { entry, state: 'idle' })
|
||||
}
|
||||
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
|
||||
status.update((draft) => { draft[id] = state })
|
||||
}
|
||||
|
||||
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
|
||||
// doLoad arms the slot before executing and reconciles the id after.
|
||||
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
|
||||
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
|
||||
win.DSHClientProxy = {
|
||||
loadPlugin: (handoff: ClientPluginHandoff): void => {
|
||||
if (slot !== NOT_LOADED) {
|
||||
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
|
||||
}
|
||||
slot = handoff
|
||||
},
|
||||
}
|
||||
|
||||
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
})
|
||||
|
||||
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
|
||||
const requireModule = (spec: string): unknown => {
|
||||
if (!modules.has(spec)) {
|
||||
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
|
||||
}
|
||||
return modules.get(spec)
|
||||
}
|
||||
|
||||
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
|
||||
const claimStyles = (id: string): void => {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
|
||||
el.setAttribute('data-plugin', id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or reuse) the parallelizable fetch half. */
|
||||
const prefetch = (record: PluginRecord): Promise<string> =>
|
||||
(record.fetch ??= fetchBundle(record.entry.url))
|
||||
|
||||
async function doLoad(record: PluginRecord): Promise<void> {
|
||||
const { id } = record.entry
|
||||
record.state = 'loading'
|
||||
publish(id, 'loading')
|
||||
try {
|
||||
// Dependencies must already be active (start() sequences this; direct
|
||||
// load() callers get the same fail-loud check).
|
||||
for (const dep of record.entry.inject) {
|
||||
const depRecord = records.get(dep)
|
||||
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
|
||||
}
|
||||
const code = await prefetch(record)
|
||||
executeBundle(code, record.entry.url)
|
||||
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
|
||||
const handoff = slot
|
||||
slot = NOT_LOADED
|
||||
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
|
||||
const exports = handoff.factory(requireModule)
|
||||
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
|
||||
// The whole export surface is the plugin: cordis object-plugin form
|
||||
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
|
||||
// silently drop the dependency declaration — postmortem 0001).
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
// Register under both specifier forms bundles emit: the bare package
|
||||
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
|
||||
// form) — the loaded surface IS the client half either way.
|
||||
modules.set(id, exports)
|
||||
modules.set(`${id}/client`, exports)
|
||||
claimStyles(id)
|
||||
record.state = 'active'
|
||||
publish(id, 'active')
|
||||
} catch (error) {
|
||||
record.state = 'failed'
|
||||
publish(id, 'failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const load = (id: string): Promise<void> => {
|
||||
const record = records.get(id)
|
||||
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
|
||||
record.load ??= doLoad(record)
|
||||
return record.load
|
||||
}
|
||||
|
||||
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
|
||||
const topo = (ids: string[]): string[] => {
|
||||
const pool = new Set(ids)
|
||||
const ordered: string[] = []
|
||||
const done = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const visit = (id: string): void => {
|
||||
if (done.has(id)) return
|
||||
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
|
||||
visiting.add(id)
|
||||
const record = records.get(id)
|
||||
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
|
||||
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
|
||||
for (const dep of record.entry.inject) {
|
||||
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (pool.has(dep)) visit(dep)
|
||||
}
|
||||
visiting.delete(id)
|
||||
done.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
for (const id of ids) visit(id)
|
||||
return ordered
|
||||
}
|
||||
|
||||
let settledPromise: Promise<void> | undefined
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const all = [...records.values()]
|
||||
const early = all.filter(r => r.entry.immediately === true)
|
||||
const rest = all.filter(r => r.entry.immediately !== true)
|
||||
// Early group: parallel fetch (all requests in flight at once), serial
|
||||
// inject-topology execution, full-group barrier before anything else.
|
||||
const earlyOrder = topo(early.map(r => r.entry.id))
|
||||
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
|
||||
for (const id of earlyOrder) await load(id)
|
||||
// Remaining plugins: one by one in inject topology.
|
||||
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
settledPromise ??= run()
|
||||
// Failures surface through settled()/status — start() itself is fire-and-forget.
|
||||
settledPromise.catch(() => {})
|
||||
},
|
||||
load,
|
||||
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
|
||||
settled: () => {
|
||||
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
|
||||
return settledPromise
|
||||
},
|
||||
requireModule,
|
||||
status,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
|
||||
// Immutability contract: every change swaps the top-level object; unchanged
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
|
||||
| { kind: 'other'; block: unknown }
|
||||
|
||||
/**
|
||||
* core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
|
||||
* @param content - core content blocks verbatim.
|
||||
* @returns UI-classified blocks in source order.
|
||||
*/
|
||||
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
|
||||
return content.map(toAssistantBlock)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
|
||||
* @param block - one core content block.
|
||||
* @returns the UI classification.
|
||||
*/
|
||||
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
switch (block.type) {
|
||||
case 'text': return { kind: 'text', text: block.text }
|
||||
case 'reasoning': return { kind: 'reasoning', text: block.text }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
|
||||
default: return { kind: 'other', block }
|
||||
}
|
||||
}
|
||||
|
||||
/** A finalized user message. */
|
||||
export interface UserMessageNode {
|
||||
kind: 'user'
|
||||
seq: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** A finalized (or interruption-frozen) assistant message. */
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
seq: number
|
||||
turn: number
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
usage?: unknown
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** A steering message injected mid-turn. */
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
seq: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** A context/system injection surfaced in the flow. */
|
||||
export interface ContextMessageNode {
|
||||
kind: 'context'
|
||||
seq: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** A tool result paired (when in-window) with its call head. */
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
seq: number
|
||||
callId: string
|
||||
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
|
||||
call: { name: string; argsRaw: string } | null
|
||||
content: readonly ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
meta?: unknown
|
||||
/** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
|
||||
callView: ToolCallView | null
|
||||
/** Host-computed render intent from this tool/result's wire view; null = same default. */
|
||||
resultView: ToolResultView | null
|
||||
}
|
||||
|
||||
/** Fallback for surface events this UI version does not know. */
|
||||
export interface UnknownSurfaceNode {
|
||||
kind: 'unknown'
|
||||
seq: number
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
| AssistantMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Approval/question placeholder cards (visible, not answerable;
|
||||
* rpcId = the requested frame's envelope id, the future respond backfill key). */
|
||||
export type PendingInteraction =
|
||||
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
|
||||
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
|
||||
|
||||
/** In-progress assistant output (chunk accumulator product). */
|
||||
export interface PartialAssistant {
|
||||
turn: number
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
}
|
||||
|
||||
/** History-open lifecycle of a Session window. */
|
||||
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
|
||||
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
|
||||
export interface PromptError {
|
||||
op: 'send' | 'stop'
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
pending: readonly PendingInteraction[]
|
||||
running: boolean
|
||||
/** Set after host/session-removed; the UI grays out and disables input. */
|
||||
removed: boolean
|
||||
openState: OpenState
|
||||
openError: RpcError | null
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
lastAgentError: string | null
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
|
||||
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
|
||||
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
|
||||
// the degradation lives in one branch function in this file, zero scattered removal points).
|
||||
|
||||
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 type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
export interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
|
||||
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
|
||||
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
|
||||
* place a synthetic event enters the window). */
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
|
||||
case 'context/message':
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
|
||||
}
|
||||
}
|
||||
|
||||
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
|
||||
export class FoldAdapter {
|
||||
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
|
||||
private padded: SessionEvent[] = []
|
||||
private baseSeq = 0
|
||||
private surface = new SurfaceManager(this.padded)
|
||||
private nodeCache = new Map<number, ConversationNode>()
|
||||
private degraded = false
|
||||
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>()
|
||||
/** 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. */
|
||||
private rev = 0
|
||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
||||
|
||||
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||
return this.callIdx
|
||||
}
|
||||
|
||||
/**
|
||||
* Window rebuild (after open/resync/page prepend): new padded array, new
|
||||
* SurfaceManager, cleared cache, rebuilt callIndex.
|
||||
* @param events - the new window contents (seq-ascending).
|
||||
* @param baseSeq - seq of the window head (sentinels pad below it).
|
||||
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
this.baseSeq = baseSeq
|
||||
this.padded = []
|
||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = false
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail append (live session/event): push into the same array (incremental
|
||||
* lazy fold applies) + incremental callIndex upkeep.
|
||||
* @param event - the live event (seq = window tail + 1).
|
||||
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current node array + degradation flag. Same revision -> same array
|
||||
* reference (memo boundary); node object references always come from the per-seq cache.
|
||||
* @returns the fold projection for the current window revision.
|
||||
*/
|
||||
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
|
||||
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
|
||||
let seqs: readonly number[]
|
||||
if (this.degraded) {
|
||||
seqs = this.degradedSeqs()
|
||||
} else {
|
||||
try {
|
||||
seqs = this.surface.nodes
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
|
||||
this.degraded = true
|
||||
seqs = this.degradedSeqs()
|
||||
}
|
||||
}
|
||||
const out: ConversationNode[] = []
|
||||
for (const seq of seqs) {
|
||||
const cached = this.nodeCache.get(seq)
|
||||
if (cached !== undefined) {
|
||||
out.push(cached)
|
||||
continue
|
||||
}
|
||||
const event = this.padded[seq]
|
||||
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
|
||||
if (event === undefined) continue
|
||||
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
|
||||
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||
private degradedSeqs(): number[] {
|
||||
const seqs: number[] = []
|
||||
for (let i = this.baseSeq; i < this.padded.length; i++) {
|
||||
const event = this.padded[i]
|
||||
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
|
||||
}
|
||||
return seqs
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(String(event.data.callId), {
|
||||
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
// (window order puts the call before its result; cannot happen on the normal path).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
|
||||
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
|
||||
// degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth). */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
|
||||
* desc, DFS children in the same order, orphans degrade to roots).
|
||||
* @param summaries - the host's session.list items.
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, SessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
const children = new Map<SessionId, SessionSummary[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const s of summaries) {
|
||||
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
|
||||
const list = children.get(s.parentSessionId) ?? []
|
||||
list.push(s)
|
||||
children.set(s.parentSessionId, list)
|
||||
} else {
|
||||
roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
|
||||
}
|
||||
}
|
||||
|
||||
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
|
||||
roots.sort(byUpdatedDesc)
|
||||
|
||||
const out: SessionListEntry[] = []
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (s: SessionSummary, depth: number): void => {
|
||||
if (visited.has(s.sessionId)) {
|
||||
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, depth })
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
kids.sort(byUpdatedDesc)
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
}
|
||||
for (const root of roots) walk(root, 0)
|
||||
// Cycle members (unreachable from any root): emit as roots so no entry is lost.
|
||||
for (const s of summaries) {
|
||||
if (!visited.has(s.sessionId)) walk(s, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionListEntry } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { Session } from './session.ts'
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<SessionId, Session>()
|
||||
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
|
||||
* history (cannot be backfilled on open), the one frame class that must not take the
|
||||
* 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 summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
private listError: RpcError | null = null
|
||||
private listInflight: Promise<void> | null = null
|
||||
|
||||
private listSnapshotCache: SessionListSnapshot
|
||||
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
|
||||
* object when every field matches — wire refreshes mint all-new summary objects, so identity
|
||||
* must be recovered by value or every SessionListItem memo misses on every refresh (audit S5). */
|
||||
private entryCache = new Map<SessionId, SessionListEntry>()
|
||||
private itemsCache: readonly SessionListEntry[] = []
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
})
|
||||
|
||||
constructor(private readonly api: IApiClient) {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
* Lazy build: return the existing instance or construct one (no auto-open —
|
||||
* open is triggered by the container's select callback).
|
||||
* @param sessionId - the session to get.
|
||||
* @returns the resident instance.
|
||||
*/
|
||||
get(sessionId: SessionId): Session {
|
||||
let session = this.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
session = new Session(sessionId, this.api)
|
||||
this.sessions.set(sessionId, session)
|
||||
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
if (summary !== undefined) session.handleRunning(summary.running)
|
||||
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
|
||||
const buffered = this.pendingBuffers.get(sessionId)
|
||||
if (buffered !== undefined) {
|
||||
this.pendingBuffers.delete(sessionId)
|
||||
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
refreshList(): Promise<void> {
|
||||
if (this.listInflight !== null) return this.listInflight
|
||||
this.listState = 'loading'
|
||||
this.listError = null
|
||||
this.notifier.markDirty()
|
||||
this.listInflight = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.list({})
|
||||
if (result.ok) {
|
||||
this.summaries = result.value.items
|
||||
this.listState = 'idle'
|
||||
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
}
|
||||
} catch (error) {
|
||||
this.listState = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
|
||||
this.listError = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.listInflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
return this.listInflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh).
|
||||
* @param cwd - optional working directory for the new session.
|
||||
* @returns the create result.
|
||||
*/
|
||||
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
|
||||
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
|
||||
this.summaries = [
|
||||
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Subscription surface (for useSessionList) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry for useSessionList.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached list snapshot (rebuilt lazily when dirty with no listeners).
|
||||
* @returns the cached reference (stable until the next flush).
|
||||
*/
|
||||
getListSnapshot(): SessionListSnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.listSnapshotCache
|
||||
}
|
||||
|
||||
// ---- ConnectionController sinks (wired by boot) ----
|
||||
|
||||
/**
|
||||
* Mux frame entry: sessionId-bearing frames go only to instantiated sessions
|
||||
* (no lazy build; non-pending frames for uninstantiated sessions drop —
|
||||
* history backfills them on open).
|
||||
* @param envelope - the frame with its wire rpcId.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question frames never hit history: buffer for replay on instantiation;
|
||||
// everything else drops (not instantiated — history fully backfills on open).
|
||||
switch (frame.type) {
|
||||
case 'approval/requested':
|
||||
case 'approval/resolved':
|
||||
case 'question/requested':
|
||||
case 'question/resolved': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
|
||||
buffer.push(envelope)
|
||||
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
|
||||
this.pendingBuffers.set(frame.sessionId, buffer)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
session.handleMuxEnvelope(envelope.rpcId, frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Host frame entry: list upkeep + per-instance running/removed/agent-error relay.
|
||||
* @param envelope - the frame with its wire rpcId.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
|
||||
this.summaries = [
|
||||
{
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
},
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.summaries = this.summaries.filter(s => s.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.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
this.summaries = this.summaries.map(s =>
|
||||
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
this.sessions.get(frame.sessionId)?.handleAgentError(frame.message)
|
||||
return // not reflected in the list
|
||||
}
|
||||
default:
|
||||
return // stream/error ignored; unknown frames ignored (documented default)
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const fresh = flattenLineage(this.summaries)
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
})
|
||||
for (const id of this.entryCache.keys()) {
|
||||
if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
|
||||
}
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
return { items: this.itemsCache, state: this.listState, error: this.listError }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Notifier: subscription + microtask-batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private scheduled = false
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
constructor(private readonly rebuild: () => void) {}
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** State-change entry: mark dirty and schedule the batched flush. */
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.dirty) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush: controlled-input writes must notify in the same tick as
|
||||
* onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
|
||||
*/
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
|
||||
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
|
||||
ensureFresh(): void {
|
||||
if (!this.dirty) return
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// PartialAccumulator: assistant/chunk accumulator.
|
||||
// Folds the six StreamChunk variants into AssistantBlock[] keyed by block index;
|
||||
// block-level immutability (a delta only swaps that block's reference).
|
||||
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
|
||||
import { toAssistantBlock } from './conversation.ts'
|
||||
|
||||
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
|
||||
export class PartialAccumulator {
|
||||
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
|
||||
private blocks: (AssistantBlock | undefined)[] = []
|
||||
private changed = true
|
||||
private snapshot: PartialAssistant
|
||||
|
||||
constructor(readonly turn: number, readonly step: number) {
|
||||
this.snapshot = { turn, step, blocks: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one chunk.
|
||||
* @param chunk - the stream chunk.
|
||||
* @returns whether it caused a visible change (usage/finish return false, skipping notification).
|
||||
*/
|
||||
push(chunk: StreamChunk): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'block-start': {
|
||||
this.blocks[chunk.index] = emptyBlock(chunk.blockType)
|
||||
this.changed = true
|
||||
return true
|
||||
}
|
||||
case 'text-delta': {
|
||||
const prev = this.blocks[chunk.index]
|
||||
this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
|
||||
this.changed = true
|
||||
return true
|
||||
}
|
||||
case 'reasoning-delta': {
|
||||
const prev = this.blocks[chunk.index]
|
||||
this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
|
||||
this.changed = true
|
||||
return true
|
||||
}
|
||||
case 'tool-call-delta': {
|
||||
const prev = this.blocks[chunk.index]
|
||||
const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
|
||||
this.blocks[chunk.index] = {
|
||||
kind: 'tool-call',
|
||||
callId: base.callId || String(chunk.id),
|
||||
name: chunk.name ?? base.name,
|
||||
argsRaw: base.argsRaw + chunk.argumentsDelta,
|
||||
}
|
||||
this.changed = true
|
||||
return true
|
||||
}
|
||||
case 'block-end': {
|
||||
this.blocks[chunk.index] = toAssistantBlock(chunk.block)
|
||||
this.changed = true
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// usage / finish / merge-extensible unknown variants: no visible block change
|
||||
// (finish is immediately followed by the assistant/message that supersedes the partial).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current partial projection.
|
||||
* @returns the cached snapshot (the blocks array reference only changes after a mutation).
|
||||
*/
|
||||
toPartial(): PartialAssistant {
|
||||
if (this.changed) {
|
||||
// Compact sparse indexes (out-of-order block-start) into render order.
|
||||
this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
|
||||
this.changed = false
|
||||
}
|
||||
return this.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
function emptyBlock(blockType: string): AssistantBlock {
|
||||
switch (blockType) {
|
||||
case 'text': return { kind: 'text', text: '' }
|
||||
case 'reasoning': return { kind: 'reasoning', text: '' }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
|
||||
default: return { kind: 'other', block: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
|
||||
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is watch-driven: a scope is minted lazily on first
|
||||
* resolution; a session leaving the list tears its scope down only when
|
||||
* nobody is watching it. "Watched" is approximated as the most recently
|
||||
* resolved binding id — SessionProvider re-resolves on every selection
|
||||
* change (keyed remount), so a switch away always re-evaluates the deferred
|
||||
* teardown; a host-side death without list removal keeps the scope (frozen
|
||||
* read-only view).
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
title: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Session list store shape. */
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each session scope fiber. */
|
||||
function sessionScope(): void {}
|
||||
|
||||
/**
|
||||
* Display title projection. The wire summary carries no title yet (P-I
|
||||
* ledger): the project directory's basename stands in, then the raw id.
|
||||
*/
|
||||
function titleOf(cwd: string | undefined, id: SessionId): string {
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
if (base !== undefined && base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
|
||||
readonly manager: SessionManager
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts.cwd)
|
||||
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-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 {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
const record = this.resolve(id)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id
|
||||
this.sweepDeferred()
|
||||
}
|
||||
return record.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
|
||||
if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
||||
const fiber = this.rootCtx.plugin(sessionScope)
|
||||
const ctx = fiber.ctx.extend({ [kScope]: id })
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session: this.manager.get(id), ctx },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const items = this.manager.getListSnapshot().items
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
title: titleOf(entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
this.list.set({ ids, byId })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (byId[id] !== undefined) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
void record.fiber.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the watched id ever defers, and every
|
||||
* watch move sweeps first, so the set cannot contain the id the watch just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||
if (this.list.getSnapshot().byId[id] !== undefined) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
void record.fiber.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Session: wraps every contract call that needs a sessionId + all conversation state for this
|
||||
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
|
||||
// created, they keep consuming mux frames in the background; React connects directly via
|
||||
// subscribe/getSnapshot.
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
|
||||
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
|
||||
|
||||
// ---- 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).
|
||||
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
|
||||
private views: (ToolEventView | undefined)[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private openState: OpenState = 'cold'
|
||||
private openError: RpcError | null = null
|
||||
private openPromise: Promise<void> | null = null
|
||||
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
|
||||
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
|
||||
* passes drop all writes once the generation moves on. */
|
||||
private openGeneration = 0
|
||||
private loadingOlder = false
|
||||
private readonly foldAdapter = new FoldAdapter()
|
||||
private partial: PartialAccumulator | null = null
|
||||
private openCalls = new Map<string, RunningToolCall>()
|
||||
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
|
||||
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
|
||||
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
|
||||
private callsRev = 0
|
||||
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
|
||||
private pendingRev = 0
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
private running = false
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
private stitching = false
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
// ---- Operations ----
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - core content blocks verbatim.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @returns the prompt result (also mirrored into promptError on failure).
|
||||
*/
|
||||
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'send', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 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()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const promise = this.doOpen(this.openGeneration).finally(() => {
|
||||
// Identity-guarded: a superseded open must not null out the promise resync just started.
|
||||
if (this.openPromise === promise) this.openPromise = null
|
||||
})
|
||||
this.openPromise = promise
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older[older.length - 1]
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.events = [...older.map(e => e.event), ...this.events]
|
||||
this.views = [...older.map(e => e.view), ...this.views]
|
||||
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
|
||||
this.rebuildDerivedFromWindow()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] loadOlder failed:', error)
|
||||
} finally {
|
||||
this.loadingOlder = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
|
||||
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.openPromise = null
|
||||
this.openState = 'cold'
|
||||
this.openError = null
|
||||
this.events = []
|
||||
this.views = []
|
||||
this.baseSeq = 0
|
||||
this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
|
||||
this.pendingRev++
|
||||
this.subscribedLastSeq = null
|
||||
this.liveBuffer = []
|
||||
this.notifier.markDirty()
|
||||
await this.open()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached conversation snapshot (rebuilt lazily when dirty with no listeners).
|
||||
* @returns the cached reference (stable until the next flush).
|
||||
*/
|
||||
getSnapshot(): ConversationSnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
// ---- Manager-only entry points (@internal; never called by the UI) ----
|
||||
|
||||
/**
|
||||
* Mux frame arrival (the dispatch switch).
|
||||
* @param rpcId - the frame envelope id (the respond backfill key for requested frames).
|
||||
* @param frame - the routed frame.
|
||||
*/
|
||||
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'session/event': {
|
||||
this.acceptLiveEvent(frame.event, frame.view)
|
||||
return
|
||||
}
|
||||
case 'session/subscribed': {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return // pure baseline bookkeeping, no visible change
|
||||
}
|
||||
case 'approval/requested': {
|
||||
this.pending.set(`a:${rpcId}`, {
|
||||
kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
|
||||
...(frame.callId !== undefined ? { callId: frame.callId } : {}),
|
||||
...(frame.reason !== undefined ? { reason: frame.reason } : {}),
|
||||
})
|
||||
this.pendingRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'approval/resolved': {
|
||||
for (const [key, item] of this.pending) {
|
||||
if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
|
||||
this.pending.delete(key)
|
||||
this.pendingRev++
|
||||
}
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'question/requested': {
|
||||
this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
|
||||
this.pendingRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'question/resolved': {
|
||||
if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
default:
|
||||
return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Running-bit relay from the host stream (list entry and snapshot stay consistent).
|
||||
* @param running - the new running state.
|
||||
*/
|
||||
handleRunning(running: boolean): void {
|
||||
if (this.running === running) return
|
||||
this.running = running
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
|
||||
handleRemoved(): void {
|
||||
this.removed = true
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* host/agent-error relay: the only outlet for live failures with no turn position.
|
||||
* @param message - the stringified error.
|
||||
*/
|
||||
handleAgentError(message: string): void {
|
||||
this.lastAgentError = message
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
|
||||
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
|
||||
dispose(): void {}
|
||||
|
||||
// ---- 私有 ----
|
||||
|
||||
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
|
||||
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.openState = 'loading'
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
this.openState = 'error'
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore)
|
||||
// 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)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
if (generation !== this.openGeneration) return
|
||||
this.openState = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
|
||||
this.openError = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.openGeneration) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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): 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
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Seq-guarded append shared by stitching and the open-state live path. */
|
||||
private appendLive(event: SessionEvent, view?: ToolEventView): void {
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.foldAdapter.append(event, view)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
|
||||
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
|
||||
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
|
||||
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
|
||||
* its continuity assertion into the degraded view). */
|
||||
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (this.openState === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push({ event, view })
|
||||
return
|
||||
}
|
||||
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (tailSeq !== null && event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push({ event, view })
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
this.appendLive(event, view)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
|
||||
* installWindow path. No openState transition — the UI keeps the current window (no loading
|
||||
* flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
|
||||
private async repairGap(): Promise<void> {
|
||||
/* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
try {
|
||||
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)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
} finally {
|
||||
this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
this.partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
this.partial.push(chunk)
|
||||
return
|
||||
}
|
||||
case 'assistant/message': {
|
||||
if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
|
||||
this.partial = null // finalize swaps in place (same notification batch, no flicker)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'tool/call': {
|
||||
this.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,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
|
||||
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
|
||||
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
|
||||
// from the logged chunks. Content-free partials are dropped outright.
|
||||
if (this.partial !== null && this.partial.turn === event.data.turn) {
|
||||
const { blocks } = this.partial.toPartial()
|
||||
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
}
|
||||
this.partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of this.openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
this.openCalls.delete(callId)
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
this.frozenRev++
|
||||
}
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
this.callsRev++
|
||||
this.frozenNodes = []
|
||||
this.frozenRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.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.applyEventSideEffects(event, this.views[i])
|
||||
}
|
||||
}
|
||||
|
||||
private windowTailSeq(): number | null {
|
||||
const tail = this.events[this.events.length - 1]
|
||||
return tail === undefined ? null : tail.seq
|
||||
}
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||
// reference across snapshot swaps (§A.9.4).
|
||||
let nodes: readonly ConversationNode[]
|
||||
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
|
||||
nodes = this.nodesCache.value
|
||||
} else {
|
||||
nodes = this.frozenNodes.length === 0
|
||||
? folded
|
||||
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
|
||||
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
|
||||
}
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
}
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
foldDegraded: degraded,
|
||||
partial: this.partial?.toPartial() ?? null,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
running: this.running,
|
||||
removed: this.removed,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
|
||||
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
|
||||
* run through the caller's ctx.effect so a plugin's registrations are
|
||||
* collected when its fiber unloads (cordis-native cascade).
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/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. */
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from './index.ts'
|
||||
|
||||
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'slots')
|
||||
this._core.onMutate((key) => { ctx.emit('slots/changed', key) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param spec - kind/scope spec.
|
||||
* @returns disposer.
|
||||
*/
|
||||
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param component - contributed component.
|
||||
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
|
||||
* inject factory's binding is pinned to ClientContext.
|
||||
* @returns disposer.
|
||||
*/
|
||||
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
|
||||
// Client-context registrations have exactly one ctx shape: pin Ctx to
|
||||
// ClientContext so inject factories dot services without a cast.
|
||||
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
|
||||
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot entries for a key.
|
||||
* @param key - SlotMap key.
|
||||
* @returns registered entries (stable reference between mutations).
|
||||
*/
|
||||
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
|
||||
return this._core.entries(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a defined spec.
|
||||
* @param key - SlotMap key.
|
||||
* @returns spec or undefined.
|
||||
*/
|
||||
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
|
||||
return this._core.spec(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
|
||||
* @param key - candidate slot key.
|
||||
* @returns wide-typed spec or undefined.
|
||||
*/
|
||||
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
|
||||
return this._core.specDynamic(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
* @param key - SlotMap key.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
|
||||
return this._core.subscribe(key, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Version counter for uSES pairing.
|
||||
* @param key - SlotMap key.
|
||||
* @returns current version.
|
||||
*/
|
||||
getVersion(key: keyof SlotMap & string): number {
|
||||
return this._core.getVersion(key)
|
||||
}
|
||||
|
||||
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
|
||||
get core(): SlotCore {
|
||||
return this._core
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Runtime plugin, node half. The implementation lives entirely in the client
|
||||
* half (src/client/ — SlotsService, SessionsService + object layer, and the
|
||||
* shell-held ClientLoader under ./loader); consumers import the /client or
|
||||
* /loader subpaths. The empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery). Contract:
|
||||
* api-contracts v3 section 4.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the runtime plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`.
|
||||
* @module @deepseek-ai/dsh-client-runtime/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
/* eslint-disable @typescript-eslint/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. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-runtime-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: every 'slots/changed'(key) emission must observe the
|
||||
* mutation already applied — SlotCore bumps the key's version synchronously
|
||||
* before the service re-emits, so a zero version at dispatch time means the
|
||||
* event fired without (or ahead of) its mutation.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'slots/changed') return
|
||||
const key: unknown = args[0]
|
||||
if (typeof key !== 'string' || key === '') {
|
||||
fail("'slots/changed' dispatched without a slot key argument")
|
||||
return
|
||||
}
|
||||
const slots = ctx.get('slots')
|
||||
// Event payloads carry keys as plain strings; getVersion is statically
|
||||
// keyed, so restore the SlotMap-key type after the runtime string check.
|
||||
if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
|
||||
fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user