diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3d97e70de5..3176428b36 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -33,7 +33,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts new file mode 100644 index 0000000000..fb98965d25 --- /dev/null +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -0,0 +1,261 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' + +/** One raw log event plus its optional envelope-level presentation view. */ +export interface ConversationEventInput { + readonly event: SessionEvent + readonly view: ToolEventView | undefined +} + +/** Definition-local identity and lifecycle role extracted from one event. */ +export interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' +} + +/** Merge-extensible business values published against one Turn. */ +export interface ConversationTurnDataMap {} + +/** Merge-extensible business values published against one Step. */ +export interface ConversationStepDataMap {} + +/** Stable keyed reader for independently owned Location business values. */ +export interface ConversationLocationDataStore { + /** + * Read one business value without exposing another owner's mutable State. + * @param key - declaration-merged business key. + * @returns latest immutable value, when its owning Context has published one. + */ + get(key: Key): Readonly | undefined +} + +interface ConversationLocationDataValue { + readonly kind: 'turn' | 'step' + readonly turn: number + readonly step?: number + readonly key: string + readonly value: unknown +} + +type RegisteredTurnData = { + [Key in keyof ConversationTurnDataMap & string]: { + readonly kind: 'turn' + readonly turn: number + readonly key: Key + readonly value: ConversationTurnDataMap[Key] + } +}[keyof ConversationTurnDataMap & string] + +type RegisteredStepData = { + [Key in keyof ConversationStepDataMap & string]: { + readonly kind: 'step' + readonly turn: number + readonly step: number + readonly key: Key + readonly value: ConversationStepDataMap[Key] + } +}[keyof ConversationStepDataMap & string] + +/** One Definition-owned value attached to an Engine-owned Turn or Step. */ +export type ConversationLocationData = + [keyof ConversationTurnDataMap | keyof ConversationStepDataMap] extends [never] + ? ConversationLocationDataValue + : RegisteredTurnData | RegisteredStepData + +/** Immutable resolved boundary for one Agent step. */ +export interface StepLocation { + readonly turn: number + readonly step: number + readonly start: SessionEvent<'step/start'> | undefined + readonly end: SessionEvent<'step/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + /** Stable reader for Step-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Immutable resolved boundary for one Agent turn. */ +export interface TurnLocation { + readonly turn: number + readonly start: SessionEvent<'turn/start'> | undefined + readonly end: SessionEvent<'turn/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + readonly steps: readonly StepLocation[] + /** Stable reader for Turn-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Engine-owned placement of one matched event in the Session hierarchy. */ +export type ConversationLocation = + | { readonly kind: 'session' } + | { readonly kind: 'turn'; readonly turn: TurnLocation } + | { readonly kind: 'step'; readonly turn: TurnLocation; readonly step: StepLocation } + | { readonly kind: 'unresolved' } + +/** One event accepted by a Definition, with its current resolved Location. */ +export interface ConversationMatch extends ConversationEventInput { + readonly role: 'start' | 'update' + readonly location: ConversationLocation +} + +/** Target-neutral identity returned by a business Definition. */ +export interface ConversationViewNode { + readonly key: string + readonly kind: string + readonly id: string + readonly target: string + readonly data: unknown +} + +/** Final Chat render unit produced directly by a business Definition. */ +export interface ChatConversationViewNode extends ConversationViewNode { + readonly target: 'chat' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly visibility: 'visible' | 'hidden' +} + +/** Immutable public view of an assembled business Context. */ +export interface ConversationNodeContext { + readonly key: string + readonly kind: string + readonly id: string + readonly matches: readonly ConversationMatch[] + readonly start: ConversationMatch | undefined + readonly state: State | undefined + readonly current: ReadonlyMap +} + +/** Read-only predecessor returned to a Definition's start function. */ +export interface ConversationPreviousContext { + readonly key: string + readonly kind: string + readonly id: string + readonly startSeq: number + readonly state: Readonly + readonly matches: readonly ConversationMatch[] +} + +/** Strictly-backward Context lookup available while a start is evaluated. */ +export interface ConversationContextReader { + /** + * Find the active Context of `kind` with the greatest start seq below the + * current start event. + * @param kind - Definition kind to query. + * @returns the nearest predecessor, or undefined when absent in the current window. + */ + previous(kind: string): ConversationPreviousContext | undefined +} + +/** Requested cadence for materializing updated business State into view Nodes. */ +export type ConversationPublication = 'none' | 'animation-frame' | 'immediate' + +/** Engine-owned Location data publication phase. */ +export type ConversationLocationDataScope = 'step' | 'turn' + +/** One independently registered business Event-to-Node state machine. */ +export interface ConversationNodeDefinition { + readonly kind: string + /** + * Extract this Definition's stable business identity from one event. + * @param event - raw Session event; no Context or history access is available. + * @returns identity and lifecycle role, or null when unrelated. + */ + match(event: SessionEvent): ConversationMatchResult | null + /** + * Create State from the unique start Match. + * @param context - complete evidence currently collected for the Context. + * @param match - the start Match. + * @param reader - strictly-backward read-only Context lookup. + * @returns the State adopted by the engine. + */ + start( + context: ConversationNodeContext, + match: ConversationMatch, + reader: ConversationContextReader, + ): State + /** + * Apply one post-start update Match. + * @param context - Context with its current State. + * @param match - update Match in ascending log order. + * @returns the State adopted by the engine. + */ + update( + context: ConversationNodeContext & { readonly state: State }, + match: ConversationMatch, + ): State + /** + * Select publication cadence for one accepted Match. + * @param match - accepted Match. + * @returns requested cadence; omission defaults to immediate. + */ + publication?(match: ConversationMatch): ConversationPublication + /** + * Publish this Definition's read-only business value for one Location phase. + * The Engine evaluates every Definition first for Step and then for Turn, + * owns replacement/removal, and rejects another Context trying to publish + * the same Location key. + * @param context - latest complete Context. + * @param scope - Location hierarchy level currently being materialized. + * @returns current Location value, or null while unavailable. + */ + buildLocationData?( + context: ConversationNodeContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null + /** + * Materialize one final Node for a registered view target. + * @param context - latest complete Context. + * @param target - registered view target such as `chat`. + * @returns final Node, or null when this Context is not currently visible. + */ + buildViewNode( + context: ConversationNodeContext, + target: string, + ): ConversationViewNode | null +} + +/** Reference-stable Turn/Step facts published beside view Nodes. */ +export interface ConversationTimelineSnapshot { + readonly turnOrder: readonly number[] + readonly turns: ReadonlyMap +} + +/** Per-Session incremental builder for one view target. */ +export interface ConversationViewBuilder { + readonly empty: Snapshot + /** + * Replace the low-frequency complete materialized Node set. + * @param input - complete Nodes and current timeline. + * @returns next view snapshot. + */ + replace(input: { + readonly nodes: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot + /** + * Apply only Nodes whose materialized values changed in this transaction. + * @param input - changed Nodes and current timeline. + * @returns next view snapshot. + */ + apply(input: { + readonly upserts: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot +} + +/** Registry contribution that creates one isolated view builder per Session. */ +export interface ConversationViewDefinition { + readonly target: string + /** @returns a new Session-owned incremental builder. */ + create(): ConversationViewBuilder +} + +/** + * Build a stable collision-free key for one Definition-local business identity. + * @param kind - Definition kind. + * @param id - Definition-local business identity. + * @returns engine-owned Context key. + */ +export function conversationContextKey(kind: string, id: string): string { + return `${kind.length}:${kind}${id}` +} diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/runtime/src/client/conversation/definition-registry.ts new file mode 100644 index 0000000000..d43f494e1a --- /dev/null +++ b/packages/client/runtime/src/client/conversation/definition-registry.ts @@ -0,0 +1,60 @@ +import { Service } from 'cordis' + +/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ +export abstract class ConversationDefinitionRegistry extends Service { + protected readonly definitions = new Map() + private listeners = new Set<() => void>() + private cached: readonly Definition[] = [] + + /** + * Return reference-stable Definitions in registration order. + * @returns current Definitions. + */ + entries(): readonly Definition[] { + return this.cached + } + + /** + * Observe low-frequency registry changes. + * @param listener - synchronous invalidation callback. + * @returns unsubscribe callback. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Register one uniquely keyed Definition for the caller's lifetime. + * @param key - registry-local unique key. + * @param definition - contributed Definition. + * @param duplicateMessage - error raised when the key is already owned. + * @param effectName - Cordis effect diagnostic label. + * @returns idempotent disposer. + */ + protected registerDefinition( + key: string, + definition: Definition, + duplicateMessage: string, + effectName: string, + ): () => void { + if (this.definitions.has(key)) throw new Error(duplicateMessage) + const owner = this.ctx + const dispose = owner.effect(() => { + this.definitions.set(key, definition) + this.refresh() + return () => { + if (this.definitions.get(key) !== definition) return + this.definitions.delete(key) + this.refresh() + } + }, effectName) + return () => { void dispose() } + } + + /** Refresh cached entries and synchronously invalidate subscribers. */ + protected refresh(): void { + this.cached = [...this.definitions.values()] + for (const listener of this.listeners) listener() + } +} diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts new file mode 100644 index 0000000000..6935ed1741 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -0,0 +1,56 @@ +import type { Context } from 'cordis' +import type { ConversationNodeDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of independently owned Conversation business Definitions. */ +export class ConversationEventRegistry extends ConversationDefinitionRegistry { + private fallback: ConversationNodeDefinition | undefined + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationEvents') + } + + /** + * Register a uniquely named business Definition for the caller's lifetime. + * @param definition - Definition contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationNodeDefinition): () => void { + return this.registerDefinition( + definition.kind, + definition, + `conversation Definition "${definition.kind}" is already registered`, + `conversationEvents.register(${JSON.stringify(definition.kind)})`, + ) + } + + /** + * Register the sole fallback used only when no ordinary Definition matches. + * @param definition - fallback Definition. + * @returns idempotent disposer. + */ + registerFallback(definition: ConversationNodeDefinition): () => void { + if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered') + const owner = this.ctx + const dispose = owner.effect(() => { + this.fallback = definition + this.refresh() + return () => { + if (this.fallback !== definition) return + this.fallback = undefined + this.refresh() + } + }, `conversationEvents.registerFallback(${JSON.stringify(definition.kind)})`) + return () => { void dispose() } + } + + /** + * Return the current unmatched-event fallback. + * @returns installed fallback, when present. + */ + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } + +} diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/runtime/src/client/conversation/view-registry.ts new file mode 100644 index 0000000000..1e2e53e141 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/view-registry.ts @@ -0,0 +1,26 @@ +import type { Context } from 'cordis' +import type { ConversationViewDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of per-target Conversation snapshot builders. */ +export class ConversationViewRegistry extends ConversationDefinitionRegistry { + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationViews') + } + + /** + * Register a uniquely named view builder factory for the caller's lifetime. + * @param definition - target builder contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationViewDefinition): () => void { + return this.registerDefinition( + definition.target, + definition, + `conversation view target "${definition.target}" is already registered`, + `conversationViews.register(${JSON.stringify(definition.target)})`, + ) + } +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b7226c0085..fe1ce8f7b0 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -10,8 +10,27 @@ import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' +import { ConversationEventRegistry } from './conversation/event-registry.ts' +import { ConversationViewRegistry } from './conversation/view-registry.ts' + +export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' export { SlotsService } from './slots.ts' +export { ConversationEventRegistry } from './conversation/event-registry.ts' +export { ConversationViewRegistry } from './conversation/view-registry.ts' +export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts' +export { ConversationLocationIndex } from './sessions/conversation-location-index.ts' +export { conversationContextKey } from './contract/conversation.ts' +export type { + ChatConversationViewNode, ConversationContextReader, ConversationEventInput, + ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, + ConversationStepDataMap, + ConversationLocation, ConversationMatch, ConversationMatchResult, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation, +} from './contract/conversation.ts' +export type { ConversationRuntime } from './sessions/conversation-assembler.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' @@ -49,11 +68,17 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + LegacyConversationSlice, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' +export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts' +export { emptyAssistantBlock } from './sessions/partial.ts' +export { isTokenDelta } from './sessions/assistant-timing.ts' +export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' @@ -165,6 +190,10 @@ declare module 'cordis' { } interface Context { slots: import('./slots.ts').SlotsService + /** Event-to-business-Context Definition registry. */ + conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry + /** Per-target Conversation snapshot builder registry. */ + conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions /** Read-only history sources isolated from Chat sessions and workspace state. */ @@ -182,8 +211,12 @@ export const inject = ['connection', 'typert'] */ export function apply(ctx: Context): void { ctx.plugin(SlotsService) + const conversation = { + events: new ConversationEventRegistry(ctx), + views: new ConversationViewRegistry(ctx), + } const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionsService(ctx, connection.api) + const sessions = new SessionsService(ctx, connection.api, conversation) ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), }) diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 021c679d04..3c54c9c36d 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -1,7 +1,6 @@ -// Shared assistant step-timing fold: both transcript projections (the live -// window adapter and the trajectory history fold) derive AssistantTiming from -// the same step/start -> first token delta -> assistant/message sequence, so -// the derivation lives once here instead of drifting per projection. +// Shared assistant step-timing fold: Chat Definitions and the Trajectory +// history fold derive AssistantTiming from the same step/start -> first token +// delta -> assistant/message sequence. import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts new file mode 100644 index 0000000000..3b6fbd6266 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -0,0 +1,798 @@ +import type { + ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, +} from '../contract/conversation.ts' +import { conversationContextKey } from '../contract/conversation.ts' +import { + ConversationLocationIndex, type ConversationLocationDataChange, +} from './conversation-location-index.ts' + +interface Dependency { + readonly kind: string + readonly key: string | undefined + readonly revision: number | undefined + readonly windowGap: boolean +} + +interface InternalContext { + readonly key: string + readonly kind: string + readonly id: string + readonly definition: ConversationNodeDefinition + startSeq: number | undefined + start: ConversationMatch | undefined + matches: ConversationMatch[] + state: unknown + revision: number + readonly current: Map + readonly locationData: Record + dependencies: Map +} + +interface PendingMatch { + readonly definition: ConversationNodeDefinition + readonly id: string + readonly match: ConversationMatch +} + +interface ViewState { + readonly target: string + readonly builder: ConversationViewBuilder + snapshot: unknown +} + +const PUBLICATION_RANK: Record = { + none: 0, + 'animation-frame': 1, + immediate: 2, +} + +const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn'] + +function emptyLocationData(): Record { + return { step: null, turn: null } +} + +function maximumPublication( + left: ConversationPublication, + right: ConversationPublication, +): ConversationPublication { + return PUBLICATION_RANK[left] >= PUBLICATION_RANK[right] ? left : right +} + +function startSeq(context: InternalContext): number | undefined { + return context.startSeq +} + +function insertionIndex(contexts: readonly InternalContext[], seq: number): number { + let low = 0 + let high = contexts.length + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + const candidate = contexts[middle] + if (candidate !== undefined && (candidate.startSeq as number) < seq) low = middle + 1 + else high = middle + } + return low +} + +function contextSnapshot(context: InternalContext): ConversationNodeContext { + return { + key: context.key, + kind: context.kind, + id: context.id, + matches: context.matches, + start: context.start, + state: context.state as State | undefined, + current: context.current, + } +} + +function mergeMatches( + key: string, + additions: readonly ConversationMatch[], + existing: readonly ConversationMatch[], +): ConversationMatch[] { + const merged: ConversationMatch[] = [] + let added = 0 + let current = 0 + while (added < additions.length || current < existing.length) { + const left = additions[added] + const right = existing[current] + if (left !== undefined && right !== undefined && left.event.seq === right.event.seq) { + throw new Error(`conversation Context ${key} received duplicate Match ${left.event.seq}`) + } + if (right === undefined || (left !== undefined && left.event.seq < right.event.seq)) { + merged.push(left as ConversationMatch) + added++ + } else { + merged.push(right) + current++ + } + } + return merged +} + +/** Event Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationEventDefinitions { + /** @returns ordinary Definitions in registration order. */ + entries(): readonly ConversationNodeDefinition[] + /** @returns unmatched-event fallback, when registered. */ + fallbackEntry(): ConversationNodeDefinition | undefined +} + +/** View Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationViewDefinitions { + /** @returns view builder factories in registration order. */ + entries(): readonly ConversationViewDefinition[] +} + +/** + * Session-owned incremental engine that assembles business Contexts from a + * contiguous Event window and materializes registered view snapshots. + */ +export class ConversationNodeAssembler { + private readonly contexts = new Map() + private readonly contextsByKind = new Map() + private readonly contextsBySeq = new Map>() + private readonly inputs = new Map() + private readonly locationIndex = new ConversationLocationIndex() + private readonly dirty = new Set() + private readonly revised = new Set() + private readonly dependents = new Map>() + private readonly views = new Map() + private hasMore = false + private replacePending = true + private timelineDirty = true + + /** + * @param eventDefinitions - live Event Definition registry. + * @param viewDefinitions - live view builder registry. + */ + constructor( + private readonly eventDefinitions: ConversationEventDefinitions, + private readonly viewDefinitions: ConversationViewDefinitions, + ) { + this.resetViewBuilders() + } + + /** + * Replace the complete loaded window after open, resync, or gap repair. + * @param entries - complete contiguous window. + * @param hasMore - whether older history remains outside the window. + * @returns immediate publication request. + */ + replaceWindow(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.contexts.clear() + this.contextsByKind.clear() + this.contextsBySeq.clear() + this.inputs.clear() + this.dirty.clear() + this.revised.clear() + this.dependents.clear() + this.hasMore = hasMore + const sorted = [...entries].sort((left, right) => left.event.seq - right.event.seq) + for (const entry of sorted) this.inputs.set(entry.event.seq, entry) + this.locationIndex.rebuild(sorted) + this.timelineDirty = true + for (const entry of sorted) this.matchInput(entry) + this.replayDependencies() + this.revised.clear() + for (const context of this.contexts.values()) this.dirty.add(context) + this.replacePending = true + return 'immediate' + } + + /** + * Add one contiguous live tail event without scanning existing Contexts. + * @param input - appended Event and optional wire view. + * @returns highest requested publication cadence. + */ + append(input: ConversationEventInput): ConversationPublication { + if (this.inputs.has(input.event.seq)) return 'none' + this.revised.clear() + this.inputs.set(input.event.seq, input) + let publication: ConversationPublication = 'none' + if (isLocationBoundary(input.event.type)) { + const previousTimeline = this.locationIndex.snapshot() + const changed = this.locationIndex.appendBoundary(input.event) + if (this.locationIndex.snapshot() !== previousTimeline) { + this.timelineDirty = true + publication = 'immediate' + } + this.replayContexts(this.refreshMatchLocations(changed)) + if (changed.size > 0) publication = 'immediate' + } else { + this.locationIndex.appendNonBoundary(input.event) + } + publication = maximumPublication(publication, this.matchInput(input)) + if (this.replayRevisedDependents()) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Add an older page while preserving existing Context and view identities. + * @param entries - newly loaded older Events. + * @param hasMore - whether history still precedes the expanded window. + * @returns highest requested publication cadence. + */ + prepend(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.revised.clear() + let publication: ConversationPublication = 'none' + const previousHasMore = this.hasMore + const fresh = entries + .filter(entry => !this.inputs.has(entry.event.seq)) + .sort((left, right) => left.event.seq - right.event.seq) + for (const entry of fresh) this.inputs.set(entry.event.seq, entry) + this.hasMore = hasMore + const previousTimeline = this.locationIndex.snapshot() + const changedLocations = this.locationIndex.rebuild(this.sortedInputs()) + if (this.locationIndex.snapshot() !== previousTimeline) this.timelineDirty = true + const affected = this.refreshMatchLocations(changedLocations) + const pending = new Map() + for (const entry of fresh) { + publication = maximumPublication(publication, this.collectInput(entry, pending)) + } + this.applyPendingMatches(pending, affected) + this.replayContexts(affected) + if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { + publication = 'immediate' + } + if (changedLocations.size > 0) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Rebuild against the current Registry set after a low-frequency plugin change. + * @returns immediate publication request. + */ + rebuildRegistry(): ConversationPublication { + this.resetViewBuilders() + return this.replaceWindow(this.sortedInputs(), this.hasMore) + } + + /** + * Materialize dirty Contexts and advance every registered view builder. + * @returns whether any view snapshot was rebuilt or incrementally applied. + */ + flush(): boolean { + if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false + if (this.replacePending) { + this.replaceLocationData() + const allByTarget = new Map() + for (const target of this.views.keys()) allByTarget.set(target, []) + for (const context of this.contexts.values()) { + for (const target of this.views.keys()) { + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) allByTarget.get(target)?.push(node) + } + } + for (const view of this.views.values()) { + view.snapshot = view.builder.replace({ + nodes: allByTarget.get(view.target) ?? [], + timeline: this.locationIndex.snapshot(), + }) + } + this.replacePending = false + this.dirty.clear() + this.timelineDirty = false + return true + } + + const upsertsByTarget = new Map() + for (const target of this.views.keys()) upsertsByTarget.set(target, []) + if (this.applyDirtyLocationData()) this.timelineDirty = true + for (const context of this.dirty) { + for (const target of this.views.keys()) { + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) + } + context.current.set(target, node) + if (node !== null) upsertsByTarget.get(target)?.push(node) + } + } + this.dirty.clear() + const timelineDirty = this.timelineDirty + this.timelineDirty = false + for (const view of this.views.values()) { + const upserts = upsertsByTarget.get(view.target) ?? [] + if (upserts.length === 0 && !timelineDirty) continue + view.snapshot = view.builder.apply({ + upserts, + timeline: this.locationIndex.snapshot(), + }) + } + return true + } + + /** + * Read the latest snapshot of a registered target. + * @param target - registered view target. + * @returns target snapshot, or undefined when no builder is registered. + */ + snapshot(target: string): unknown { + return this.views.get(target)?.snapshot + } + + private sortedInputs(): ConversationEventInput[] { + return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) + } + + private matchInput(input: ConversationEventInput): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => + this.acceptMatch(definition, id, role, input)) + } + + private collectInput( + input: ConversationEventInput, + pending: Map, + ): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => { + const key = conversationContextKey(definition.kind, id) + const match: ConversationMatch = { + ...input, + role, + location: this.locationIndex.locationOf(input.event), + } + const matches = pending.get(key) ?? [] + matches.push({ definition, id, match }) + pending.set(key, matches) + return definition.publication?.(match) ?? 'immediate' + }) + } + + private dispatchInput( + input: ConversationEventInput, + accept: ( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + ) => ConversationPublication, + ): ConversationPublication { + let matched = false + let publication: ConversationPublication = 'none' + for (const definition of this.eventDefinitions.entries()) { + const result = definition.match(input.event) + if (result === null) continue + matched = true + publication = maximumPublication(publication, accept(definition, result.id, result.role)) + } + if (!matched) { + const fallback = this.eventDefinitions.fallbackEntry() + const result = fallback?.match(input.event) ?? null + if (fallback !== undefined && result !== null) { + publication = maximumPublication(publication, accept(fallback, result.id, result.role)) + } + } + return publication + } + + private acceptMatch( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + input: ConversationEventInput, + ): ConversationPublication { + const key = conversationContextKey(definition.kind, id) + let context = this.contexts.get(key) + if (role === 'start' && context?.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + if (context === undefined) { + context = { + key, + kind: definition.kind, + id, + definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + const match: ConversationMatch = { + ...input, + role, + location: this.locationIndex.locationOf(input.event), + } + const previous = context.matches.at(-1) + if (previous !== undefined && previous.event.seq >= input.event.seq) { + throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`) + } + if (role === 'start' && context.matches.length > 0) { + throw new Error(`conversation Context ${key} received an update before its start Match`) + } + context.matches.push(match) + if (role === 'start') { + context.startSeq = input.event.seq + context.start = match + this.indexStartedContext(context) + } + const owners = this.contextsBySeq.get(input.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(input.event.seq, owners) + + if (role === 'start') { + this.replayContext(context) + } else if (context.state !== undefined) { + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState(definition, 'update', definition.update(typed, match)) + context.revision++ + this.revised.add(context) + } + this.dirty.add(context) + return definition.publication?.(match) ?? 'immediate' + } + + private applyPendingMatches( + pending: ReadonlyMap, + affected: Set, + ): void { + const startsByKind = new Map() + for (const [key, entries] of pending) { + const first = entries[0] + if (first === undefined) continue + let context = this.contexts.get(key) + if (context === undefined) { + context = { + key, + kind: first.definition.kind, + id: first.id, + definition: first.definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + let discoveredStart: ConversationMatch | undefined + const additions = entries + .map((entry) => { + if (entry.definition !== context.definition || entry.id !== context.id) { + throw new Error(`conversation Context ${key} received inconsistent Definition identity`) + } + if (entry.match.role === 'start') { + if (discoveredStart !== undefined || context.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + discoveredStart = entry.match + } + const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(entry.match.event.seq, owners) + return entry.match + }) + .sort((left, right) => left.event.seq - right.event.seq) + context.matches = mergeMatches(context.key, additions, context.matches) + if (discoveredStart !== undefined) { + context.start = discoveredStart + context.startSeq = discoveredStart.event.seq + const starts = startsByKind.get(context.kind) ?? [] + starts.push(context) + startsByKind.set(context.kind, starts) + } + if (context.start !== undefined && context.matches[0] !== context.start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + affected.add(context) + this.dirty.add(context) + } + for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) + } + + private replayContexts(contexts: ReadonlySet): void { + const ordered = [...contexts].sort((left, right) => + (left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY)) + for (const context of ordered) { + if (context.start === undefined) { + context.state = undefined + this.dirty.add(context) + continue + } + this.replayContext(context) + } + } + + private replayContext(context: InternalContext): void { + const start = context.start + if (start === undefined) { + context.state = undefined + return + } + if (context.matches[0] !== start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + const dependencies = new Map() + const reader = this.readerFor(start.event.seq, dependencies) + context.state = undefined + context.state = requireState( + context.definition, + 'start', + context.definition.start(contextSnapshot(context), start, reader), + ) + this.replaceDependencies(context, dependencies) + for (let index = 1; index < context.matches.length; index++) { + const match = context.matches[index] + if (match === undefined || match.role !== 'update') continue + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState( + context.definition, + 'update', + context.definition.update(typed, match), + ) + } + context.revision++ + this.revised.add(context) + this.dirty.add(context) + } + + private replaceDependencies(context: InternalContext, dependencies: Map): void { + for (const dependency of context.dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) + current?.delete(context) + if (current?.size === 0) this.dependents.delete(dependency.key) + } + context.dependencies = dependencies + for (const dependency of dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) ?? new Set() + current.add(context) + this.dependents.set(dependency.key, current) + } + } + + private replayRevisedDependents(): boolean { + const pending = [...this.revised] + const replayed = new Set() + for (let index = 0; index < pending.length; index++) { + const dependency = pending[index] + if (dependency === undefined) continue + for (const dependent of this.dependents.get(dependency.key) ?? []) { + if (replayed.has(dependent)) continue + replayed.add(dependent) + this.replayContext(dependent) + pending.push(dependent) + } + } + return replayed.size > 0 + } + + private readerFor( + beforeSeq: number, + dependencies: Map, + ): ConversationContextReader { + return { + previous: (kind: string): ConversationPreviousContext | undefined => { + const predecessor = this.previousContext(kind, beforeSeq) + dependencies.set(kind, { + kind, + key: predecessor?.key, + revision: predecessor?.revision, + windowGap: predecessor === undefined && this.hasMore, + }) + if (predecessor?.state === undefined) return undefined + const seq = startSeq(predecessor) + if (seq === undefined) return undefined + return { + key: predecessor.key, + kind: predecessor.kind, + id: predecessor.id, + startSeq: seq, + state: predecessor.state as Readonly, + matches: predecessor.matches, + } + }, + } + } + + private previousContext(kind: string, beforeSeq: number): InternalContext | undefined { + const candidates = this.contextsByKind.get(kind) ?? [] + const indexBefore = insertionIndex(candidates, beforeSeq) + for (let index = indexBefore - 1; index >= 0; index--) { + const candidate = candidates[index] + if (candidate?.state !== undefined) return candidate + } + return undefined + } + + /** Insert one newly discovered start into its Definition's ordered predecessor index. */ + private indexStartedContext(context: InternalContext): void { + const seq = context.startSeq + if (seq === undefined) return + const candidates = this.contextsByKind.get(context.kind) ?? [] + const previous = candidates.at(-1) + if (previous === undefined || (previous.startSeq as number) < seq) candidates.push(context) + else candidates.splice(insertionIndex(candidates, seq), 0, context) + this.contextsByKind.set(context.kind, candidates) + } + + private indexStartedContexts(kind: string, additions: readonly InternalContext[]): void { + if (additions.length === 0) return + const sorted = [...additions].sort((left, right) => + (left.startSeq as number) - (right.startSeq as number)) + const existing = this.contextsByKind.get(kind) ?? [] + const merged: InternalContext[] = [] + let before = 0 + let added = 0 + while (before < existing.length || added < sorted.length) { + const left = existing[before] + const right = sorted[added] + if (right === undefined || (left !== undefined && (left.startSeq as number) < (right.startSeq as number))) { + merged.push(left as InternalContext) + before++ + } else { + merged.push(right) + added++ + } + } + this.contextsByKind.set(kind, merged) + } + + private replayDependencies(): boolean { + let replayed = false + const ordered = [...this.contexts.values()] + .filter(context => startSeq(context) !== undefined) + .sort((left, right) => (startSeq(left) as number) - (startSeq(right) as number)) + for (const context of ordered) { + if (context.state === undefined || context.dependencies.size === 0) continue + const before = startSeq(context) + if (before === undefined) continue + let changed = false + for (const dependency of context.dependencies.values()) { + const current = this.previousContext(dependency.kind, before) + const windowGap = current === undefined && this.hasMore + if (current?.key !== dependency.key + || current?.revision !== dependency.revision + || windowGap !== dependency.windowGap) { + changed = true + break + } + } + if (changed) { + this.replayContext(context) + replayed = true + } + } + return replayed + } + + private refreshMatchLocations(changedSeqs: ReadonlySet): Set { + const affected = new Set() + if (changedSeqs.size === 0) return affected + for (const seq of changedSeqs) { + for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context) + } + for (const context of affected) { + let start = context.start + const matches = context.matches.map((match): ConversationMatch => { + if (!changedSeqs.has(match.event.seq)) return match + const refreshed = { ...match, location: this.locationIndex.locationOf(match.event) } + if (match === start) start = refreshed + return refreshed + }) + context.matches = matches + context.start = start + } + return affected + } + + private buildNode(context: InternalContext, target: string): ConversationViewNode | null { + const node = context.definition.buildViewNode(contextSnapshot(context), target) + if (node === null) return null + if (node.key !== context.key) { + throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`) + } + if (node.target !== target) { + throw new Error(`conversation Definition "${context.kind}" returned target "${node.target}" while building "${target}"`) + } + return node + } + + private buildLocationData( + context: InternalContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null { + const build = context.definition.buildLocationData + if (build === undefined) return null + const data = build(contextSnapshot(context), scope) + if (data === null) return null + if (data.kind !== scope) { + throw new Error( + `conversation Definition "${context.kind}" published ${data.kind} data through its ${scope} scope`, + ) + } + if (data.key !== context.kind) { + throw new Error( + `conversation Definition "${context.kind}" published Location data key "${data.key}"; expected its owned kind`, + ) + } + if (!Number.isSafeInteger(data.turn) || data.turn < 0) { + throw new Error(`conversation Definition "${context.kind}" published invalid turn ${data.turn}`) + } + if (data.kind === 'step' && (!Number.isSafeInteger(data.step) || (data.step as number) < 0)) { + throw new Error(`conversation Definition "${context.kind}" published invalid step ${String(data.step)}`) + } + return data + } + + private replaceLocationData(): void { + const entries: { owner: string; data: ConversationLocationData }[] = [] + for (const scope of LOCATION_DATA_SCOPES) { + for (const context of this.contexts.values()) { + const data = this.buildLocationData(context, scope) + context.locationData[scope] = data + if (data !== null) entries.push({ owner: context.key, data }) + } + this.locationIndex.replaceData(entries) + } + } + + private applyDirtyLocationData(): boolean { + let changed = false + for (const scope of LOCATION_DATA_SCOPES) { + const changes: ConversationLocationDataChange[] = [] + for (const context of this.dirty) { + const previous = context.locationData[scope] + const next = this.buildLocationData(context, scope) + context.locationData[scope] = next + if (previous !== next) changes.push({ owner: context.key, previous, next }) + } + changed = this.locationIndex.applyData(changes) || changed + } + return changed + } + + private resetViewBuilders(): void { + this.views.clear() + for (const definition of this.viewDefinitions.entries()) { + const builder = definition.create() + this.views.set(definition.target, { + target: definition.target, + builder, + snapshot: builder.empty, + }) + } + this.replacePending = true + } +} + +function isLocationBoundary(type: string): boolean { + return type === 'turn/start' || type === 'turn/end' || type === 'step/start' || type === 'step/end' +} + +function requireState( + definition: ConversationNodeDefinition, + phase: 'start' | 'update', + state: unknown, +): unknown { + if (state === undefined) { + throw new Error(`conversation Definition "${definition.kind}" returned undefined from ${phase}()`) + } + return state +} + +/** Structural registry pair accepted by Session and SessionManager. */ +export interface ConversationRuntime { + readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void } + readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void } +} diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts new file mode 100644 index 0000000000..20fe3efdff --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -0,0 +1,508 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationEventInput, ConversationLocation, ConversationLocationData, + ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot, + ConversationTurnDataMap, StepLocation, TurnLocation, +} from '../contract/conversation.ts' + +interface OwnedLocationData { + readonly owner: string + readonly value: unknown +} + +/** One Context's previous and next Location-data publication. */ +export interface ConversationLocationDataChange { + readonly owner: string + readonly previous: ConversationLocationData | null + readonly next: ConversationLocationData | null +} + +class MutableLocationDataStore { + private entries = new Map() + + get(key: Key): unknown { + return this.entries.get(key)?.value + } + + remove(owner: string, key: string): boolean { + const current = this.entries.get(key) + if (current?.owner !== owner) return false + this.entries.delete(key) + return true + } + + set(owner: string, key: string, value: unknown): boolean { + const current = this.entries.get(key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${key}" is already owned by ${current.owner}`) + } + if (current?.value === value) return false + this.entries.set(key, { owner, value }) + return true + } + + replace(entries: ReadonlyMap): boolean { + let changed = this.entries.size !== entries.size + if (!changed) { + for (const [key, value] of entries) { + const current = this.entries.get(key) + if (current?.owner !== value.owner || current.value !== value.value) { + changed = true + break + } + } + } + if (changed) this.entries = new Map(entries) + return changed + } +} + +interface Coordinates { + readonly turn?: number + readonly step?: number + readonly session?: true +} + +interface StepDraft { + readonly turn: number + readonly step: number + firstSeq: number + start?: SessionEvent<'step/start'> + end?: SessionEvent<'step/end'> +} + +interface TurnDraft { + readonly turn: number + firstSeq: number + start?: SessionEvent<'turn/start'> + end?: SessionEvent<'turn/end'> + readonly steps: Map +} + +const SESSION_LOCATION = { kind: 'session' } as const +const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const + +function payloadCoordinates(event: SessionEvent): Coordinates { + const data = event.data as unknown as { turn?: unknown; step?: unknown } + if (data.turn === null) return { session: true } + const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0 + ? data.turn as number + : undefined + const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0 + ? data.step as number + : undefined + return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } } +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function sameStep(left: StepLocation | undefined, right: StepLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data +} + +function sameTurn(left: TurnLocation | undefined, right: TurnLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data && sameReferences(left.steps, right.steps) +} + +function sameLocation(left: ConversationLocation | undefined, right: ConversationLocation | undefined): boolean { + if (left === undefined || right === undefined || left.kind !== right.kind) return left === right + if (left.kind === 'session' || left.kind === 'unresolved') return true + if (right.kind === 'session' || right.kind === 'unresolved') return false + if (left.kind === 'turn' || right.kind === 'turn') { + return left.kind === 'turn' && right.kind === 'turn' && left.turn === right.turn + } + return left.turn === right.turn && left.step === right.step +} + +/** Session-owned Turn/Step timeline and event-to-Location index. */ +export class ConversationLocationIndex { + private coordinates = new Map() + private locations = new Map() + private seqsByTurn = new Map>() + private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() } + private readonly turnDataStores = new Map() + private readonly stepDataStores = new Map() + private currentTurn: number | undefined + private currentStep: number | undefined + + /** + * Return the current reference-stable timeline. + * @returns current timeline snapshot. + */ + snapshot(): ConversationTimelineSnapshot { + return this.timeline + } + + /** Replace all Definition-owned Location values while preserving reader identities. */ + replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean { + const turns = new Map>() + const steps = new Map>() + for (const { owner, data } of entries) { + const values = data.kind === 'turn' + ? turns.get(data.turn) ?? new Map() + : steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map() + const current = values.get(data.key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${data.key}" is already owned by ${current.owner}`) + } + values.set(data.key, { owner, value: data.value }) + if (data.kind === 'turn') turns.set(data.turn, values) + else steps.set(stepDataKey(data.turn, requireStep(data)), values) + } + let changed = false + for (const turn of new Set([...this.turnDataStores.keys(), ...turns.keys()])) { + changed = this.mutableTurnData(turn).replace(turns.get(turn) ?? new Map()) || changed + } + for (const step of new Set([...this.stepDataStores.keys(), ...steps.keys()])) { + changed = this.mutableStepData(step).replace(steps.get(step) ?? new Map()) || changed + } + return changed + } + + /** Apply changed Context publications without rebuilding Turn/Step membership. */ + applyData(changes: readonly ConversationLocationDataChange[]): boolean { + let changed = false + for (const change of changes) { + const previous = change.previous + if (previous === null) continue + changed = this.storeFor(previous).remove(change.owner, previous.key) || changed + } + for (const change of changes) { + const next = change.next + if (next === null) continue + changed = this.storeFor(next).set(change.owner, next.key, next.value) || changed + } + return changed + } + + /** + * Resolve the latest Location for one event. + * @param event - event already ingested into this index. + * @returns current Location, falling back to session when it has no Turn/Step affinity. + */ + locationOf(event: SessionEvent): ConversationLocation { + return this.locations.get(event.seq) ?? SESSION_LOCATION + } + + /** + * Rebuild timeline facts after replace/prepend or a boundary append. + * @param entries - complete current window in ascending seq order. + * @returns seqs whose resolved Location changed. + */ + rebuild(entries: readonly ConversationEventInput[]): ReadonlySet { + const previousLocations = this.locations + const turns = new Map() + const coordinates = new Map() + let currentTurn: number | undefined + let currentStep: number | undefined + + const turnDraft = (turn: number, seq: number): TurnDraft => { + let draft = turns.get(turn) + if (draft === undefined) { + draft = { turn, firstSeq: seq, steps: new Map() } + turns.set(turn, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + const stepDraft = (turn: number, step: number, seq: number): StepDraft => { + const owner = turnDraft(turn, seq) + let draft = owner.steps.get(step) + if (draft === undefined) { + draft = { turn, step, firstSeq: seq } + owner.steps.set(step, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + + for (const { event } of entries) { + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + currentTurn = event.data.turn + currentStep = undefined + } + if (event.type === 'step/start') { + currentTurn = event.data.turn + currentStep = event.data.step + } + if (explicit.session !== true && explicit.turn !== undefined) { + if (currentTurn !== explicit.turn) currentStep = undefined + currentTurn = explicit.turn + if (explicit.step !== undefined) currentStep = explicit.step + } + const turn = explicit.session === true ? undefined : explicit.turn ?? currentTurn + const step = explicit.session === true || event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turn === currentTurn ? currentStep : undefined) + coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) turnDraft(turn, event.seq) + if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq) + + if (event.type === 'turn/start') { + turnDraft(event.data.turn, event.seq).start = event + } else if (event.type === 'turn/end') { + turnDraft(event.data.turn, event.seq).end = event + } else if (event.type === 'step/start') { + stepDraft(event.data.turn, event.data.step, event.seq).start = event + } else if (event.type === 'step/end') { + stepDraft(event.data.turn, event.data.step, event.seq).end = event + } + + if (event.type === 'step/end' && currentTurn === event.data.turn && currentStep === event.data.step) { + currentStep = undefined + } + if (event.type === 'turn/end' && currentTurn === event.data.turn) { + currentTurn = undefined + currentStep = undefined + } + } + + const previousTurns = this.timeline.turns + const nextTurns = new Map() + const orderedDrafts = [...turns.values()].sort((left, right) => left.firstSeq - right.firstSeq) + for (const draft of orderedDrafts) { + const previousTurn = previousTurns.get(draft.turn) + const previousSteps = new Map(previousTurn?.steps.map(step => [step.step, step]) ?? []) + const steps = [...draft.steps.values()] + .sort((left, right) => left.firstSeq - right.firstSeq) + .map((candidate): StepLocation => { + const value: StepLocation = { + turn: candidate.turn, + step: candidate.step, + start: candidate.start, + end: candidate.end, + status: candidate.end !== undefined + ? 'closed' + : candidate.start === undefined ? 'unknown' : 'open', + data: this.stepData(candidate.turn, candidate.step), + } + const previous = previousSteps.get(candidate.step) + return sameStep(previous, value) ? previous as StepLocation : value + }) + const value: TurnLocation = { + turn: draft.turn, + start: draft.start, + end: draft.end, + status: draft.end !== undefined ? 'closed' : draft.start === undefined ? 'unknown' : 'open', + steps, + data: this.turnData(draft.turn), + } + nextTurns.set(draft.turn, sameTurn(previousTurn, value) ? previousTurn as TurnLocation : value) + } + + const nextOrder = orderedDrafts.map(draft => draft.turn) + const turnOrder = this.timeline.turnOrder.length === nextOrder.length + && this.timeline.turnOrder.every((turn, index) => turn === nextOrder[index]) + ? this.timeline.turnOrder + : nextOrder + let sameMap = previousTurns.size === nextTurns.size + if (sameMap) { + for (const [turn, value] of nextTurns) { + if (previousTurns.get(turn) !== value) { + sameMap = false + break + } + } + } + this.timeline = sameMap && turnOrder === this.timeline.turnOrder + ? this.timeline + : { turnOrder, turns: nextTurns } + this.coordinates = coordinates + this.locations = new Map() + this.seqsByTurn = new Map() + for (const { event } of entries) { + const coordinates = this.coordinates.get(event.seq) + if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + this.currentTurn = currentTurn + this.currentStep = currentStep + + const changed = new Set() + for (const { event } of entries) { + if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) { + changed.add(event.seq) + } + } + return changed + } + + /** + * Append one Turn/Step boundary while revisiting only the owning Turn. + * @param event - contiguous tail boundary event. + * @returns seqs whose immutable Location reference changed. + */ + appendBoundary(event: SessionEvent): ReadonlySet { + if (event.type !== 'turn/start' && event.type !== 'turn/end' + && event.type !== 'step/start' && event.type !== 'step/end') { + throw new Error(`conversation Location boundary expected, received ${event.type}`) + } + + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + this.currentTurn = event.data.turn + this.currentStep = undefined + } else if (event.type === 'step/start') { + this.currentTurn = event.data.turn + this.currentStep = event.data.step + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turnNumber = explicit.turn ?? this.currentTurn + if (turnNumber === undefined) throw new Error(`conversation boundary ${event.type} has no turn`) + const stepNumber = event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turnNumber === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + turn: turnNumber, + ...stepNumber === undefined ? {} : { step: stepNumber }, + }) + this.indexTurnSeq(turnNumber, event.seq) + + const previousTurn = this.timeline.turns.get(turnNumber) + let steps = previousTurn?.steps ?? [] + if (event.type === 'step/start' || event.type === 'step/end') { + const number = event.data.step + const previousStep = steps.find(candidate => candidate.step === number) + const candidate: StepLocation = { + turn: turnNumber, + step: number, + start: event.type === 'step/start' ? event : previousStep?.start, + end: event.type === 'step/end' ? event : previousStep?.end, + status: event.type === 'step/end' || previousStep?.end !== undefined ? 'closed' : 'open', + data: this.stepData(turnNumber, number), + } + const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate + const index = steps.findIndex(step => step.step === number) + steps = index < 0 + ? [...steps, nextStep] + : steps.map((step, at) => at === index ? nextStep : step) + } + const candidate: TurnLocation = { + turn: turnNumber, + start: event.type === 'turn/start' ? event : previousTurn?.start, + end: event.type === 'turn/end' ? event : previousTurn?.end, + status: event.type === 'turn/end' || previousTurn?.end !== undefined + ? 'closed' + : event.type === 'turn/start' || previousTurn?.start !== undefined ? 'open' : 'unknown', + steps, + data: this.turnData(turnNumber), + } + const turn = sameTurn(previousTurn, candidate) ? previousTurn as TurnLocation : candidate + const turns = new Map(this.timeline.turns) + turns.set(turnNumber, turn) + const turnOrder = previousTurn === undefined + ? [...this.timeline.turnOrder, turnNumber] + : this.timeline.turnOrder + this.timeline = { turnOrder, turns } + + const changed = new Set() + for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { + const previous = this.locations.get(seq) + const next = this.resolve(seq) + this.locations.set(seq, next) + if (!sameLocation(previous, next)) changed.add(seq) + } + + if (event.type === 'step/end' && this.currentTurn === event.data.turn && this.currentStep === event.data.step) { + this.currentStep = undefined + } + if (event.type === 'turn/end' && this.currentTurn === event.data.turn) { + this.currentTurn = undefined + this.currentStep = undefined + } + return changed + } + + /** + * Index one non-boundary tail event without rescanning the window. + * @param event - contiguous appended event. + */ + appendNonBoundary(event: SessionEvent): void { + const explicit = payloadCoordinates(event) + if (explicit.session === true) { + this.coordinates.set(event.seq, {}) + this.locations.set(event.seq, SESSION_LOCATION) + return + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turn = explicit.turn ?? this.currentTurn + const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) this.indexTurnSeq(turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + + private indexTurnSeq(turn: number, seq: number): void { + const current = this.seqsByTurn.get(turn) ?? new Set() + current.add(seq) + this.seqsByTurn.set(turn, current) + } + + private turnData(turn: number): ConversationLocationDataStore { + return this.mutableTurnData(turn) as ConversationLocationDataStore + } + + private stepData(turn: number, step: number): ConversationLocationDataStore { + return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore + } + + private mutableTurnData(turn: number): MutableLocationDataStore { + const current = this.turnDataStores.get(turn) ?? new MutableLocationDataStore() + this.turnDataStores.set(turn, current) + return current + } + + private mutableStepData(key: string): MutableLocationDataStore { + const current = this.stepDataStores.get(key) ?? new MutableLocationDataStore() + this.stepDataStores.set(key, current) + return current + } + + private storeFor(data: ConversationLocationData): MutableLocationDataStore { + return data.kind === 'turn' + ? this.mutableTurnData(data.turn) + : this.mutableStepData(stepDataKey(data.turn, requireStep(data))) + } + + private resolve(seq: number): ConversationLocation { + const coordinates = this.coordinates.get(seq) + if (coordinates?.turn === undefined) return SESSION_LOCATION + const turn = this.timeline.turns.get(coordinates.turn) + if (turn === undefined) return UNRESOLVED_LOCATION + if (coordinates.step === undefined) return { kind: 'turn', turn } + const step = turn.steps.find(candidate => candidate.step === coordinates.step) + return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step } + } +} + +function stepDataKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function requireStep(data: ConversationLocationData): number { + if (data.kind === 'step' && data.step !== undefined) return data.step + throw new Error(`conversation Step data "${data.key}" requires a step`) +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2681bb33f3..99daa7c8a5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -13,6 +13,9 @@ import type { } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' +import type { + ChatConversationViewNode, ConversationTimelineSnapshot, +} from '../contract/conversation.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -206,7 +209,7 @@ export interface CompactionSummaryNode { * Fallback for surface events this UI version does not know: the documented * default arm of `SessionEventMap`, which is merge-extensible, so the * projection's switch cannot end in `assertNever`. No event produces this node - * today — `isAppendSurfaceEvent` admits only the four types in core's + * today — `isAppendSurfaceEvent` admits only the three types in core's * `SurfaceEventType`, and each has its own arm — and it exists so widening that * set core-side degrades to a raw row instead of dropping the event silently. */ @@ -222,8 +225,8 @@ export interface UnknownSurfaceNode { /** * One slash-command lifecycle folded from the log-only `command/run` / * `command/done` pair (paired by commandId, mirroring tool call↔result). - * Log-only events are not surface events, so the TranscriptAdapter indexes - * them separately and merges the nodes into the flow by seq. A window cut + * Log-only events are not surface events, so the command Definition indexes + * them separately and the Chat builder orders the resulting node by seq. A window cut * between the pair soft-falls like tool pairs: a done with no in-window run * still builds a node (name/args null), and a run with no done renders as * still executing. @@ -311,21 +314,19 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' * Input-area shape of an OPEN session, derived at snapshot assembly (the one * place that knows the predicate — consumers switch, never re-derive): * - * - `blank`: no activity ever (no nodes, no partial, not running, no pending - * waits, no prompt attempt) — the UI renders the blank-session guidance - * hero. - * - `engaging`: the first prompt was initiated but no content landed yet — - * the UI holds the composer through the accept → running → first-event - * frames. Entered synchronously before prompt()'s first await. - * - `active`: content exists (nodes, partial, running turn, or pending - * waits) — the ordinary conversation view. + * - `blank`: the authoritative blank bit is still set and no prompt was + * attempted — the UI renders the blank-session guidance hero. + * - `engaging`: a first prompt was attempted, but no accepted turn or other + * authoritative activity signal has arrived — the UI keeps the composer + * visible through admission and error frames. + * - `active`: the session is non-blank beyond its pending first prompt, is + * running, or owns a pending interaction — the ordinary conversation view. * - * Monotone within a session object: blank → engaging → active, no returns. * A failed first prompt stays `engaging` (composer + error strip — retry - * semantics; bouncing back to the hero would discard the error context). + * semantics; returning to the hero would discard the error context). * Sessions whose window is not open (`loading`/`error`) are outside phase * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} - * first (phase still reports `active`-ish facts but must not be rendered). + * first. */ export type ComposerPhase = 'blank' | 'engaging' | 'active' @@ -335,10 +336,70 @@ export interface PromptError { error: RpcError } +/** Stable per-key reader for final Chat view Nodes. */ +export interface ChatNodeStore { + /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ + get(key: string): ChatConversationViewNode | undefined + /** @returns all currently materialized Nodes without imposing render order. */ + values(): readonly ChatConversationViewNode[] +} + +/** Stable per-Location membership index for turn-local and step-local consumers. */ +export interface ChatLocationNodeIndex { + /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ + getTurn(turn: number): readonly string[] + /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ + getStep(turn: number, step: number): readonly string[] +} + +/** Temporary projection consumed by Trajectory and unmigrated readers. */ +export interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] + readonly turnTimings: ReadonlyMap + readonly turnEnds: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +/** Incremental Chat target snapshot: stable keyed stores plus structural order. */ +export interface ChatSnapshot { + readonly order: readonly string[] + readonly nodes: ChatNodeStore + readonly locations: ChatLocationNodeIndex + readonly timeline: ConversationTimelineSnapshot + readonly legacy: LegacyConversationSlice +} + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } + +/** Empty Chat target used before a view builder is registered. */ +export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + order: EMPTY_LIST, + nodes: { + get: () => undefined, + values: () => EMPTY_LIST, + }, + locations: { + getTurn: () => EMPTY_LIST, + getStep: () => EMPTY_LIST, + }, + timeline: EMPTY_TIMELINE, + legacy: { + nodes: EMPTY_LIST, + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: EMPTY_LIST, + }, +} + /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ + /** Final Chat target assembled from independently registered business Definitions. */ + chat: ChatSnapshot + /** Legacy Trajectory slice derived from the registered Chat Definitions. */ nodes: readonly ConversationNode[] /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ turnTimings: ReadonlyMap diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..b4eec9d6df 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -10,6 +10,7 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -158,6 +159,7 @@ export class SessionManager { private readonly api: IApiClient, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, + private readonly conversation?: ConversationRuntime, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -282,7 +284,12 @@ export class SessionManager { const address = this.addresses.get(sessionId) const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries .find(entry => entry.kind === 'child' && entry.id === sessionId) - if (child?.kind === 'child') session.handleRunning(child.activity === 'running') + if (child?.kind === 'child') { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false) + session.handleRunning(child.activity === 'running') + } } } return session @@ -301,9 +308,15 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...this.conversation === undefined ? {} : { conversation: this.conversation }, }) } + /** Rebuild every resident Session after one coalesced registry transaction. */ + rebuildConversationRegistry(): void { + for (const session of this.sessions.values()) session.rebuildConversationRegistry() + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 31a04124cd..599ea61c3d 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -48,7 +48,7 @@ export class PartialAccumulator { push(chunk: StreamChunk): boolean { switch (chunk.type) { case 'block-start': { - this.blocks[chunk.index] = emptyBlock(chunk.blockType) + this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) this.changed = true return true } @@ -102,7 +102,12 @@ export class PartialAccumulator { } } -function emptyBlock(blockType: string): AssistantBlock { +/** + * Create the empty client projection for one streamed Assistant block kind. + * @param blockType - wire block kind. + * @returns empty projected block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { switch (blockType) { case 'text': return { kind: 'text', text: '' } case 'reasoning': return { kind: 'reasoning', text: '' } diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts new file mode 100644 index 0000000000..be7351cf5f --- /dev/null +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -0,0 +1,74 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { QueuedMessage } from './conversation.ts' + +const QUEUE_PREVIEW_CHARS = 200 + +function previewOf(content: readonly ContentBlock[]): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + +type QueueItems = Extract['items'] + +/** Authoritative transient queue projection and durable steering handoff. */ +export class SessionQueueMirror { + private current: readonly QueuedMessage[] = [] + + /** + * Return the current immutable queue projection. + * @returns current queue rows. + */ + snapshot(): readonly QueuedMessage[] { + return this.current + } + + /** + * Drop the stale generation before its replacement queue baseline arrives. + * @returns whether any projected queue row was removed. + */ + reset(): boolean { + if (this.current.length === 0) return false + this.current = [] + return true + } + + /** + * Replace from one authoritative stream queue frame. + * @param items - complete host queue snapshot. + */ + replace(items: QueueItems): void { + this.current = items.map(item => ({ + id: item.id, + messageId: item.message.id, + placement: item.placement, + content: item.message.content, + preview: previewOf(item.message.content), + text: textOf(item.message.content), + })) + } + + /** + * Retire a transient steering row once its durable message enters the log. + * @param event - newly contiguous durable Session event. + * @returns whether the projection changed. + */ + acceptDurable(event: SessionEvent): boolean { + if (event.type !== 'user/message') return false + const messageId = event.data.id + const index = this.current.findIndex(item => + item.placement === 'steering' && item.messageId === messageId) + if (index < 0) return false + this.current = this.current.filter((_item, candidate) => candidate !== index) + return true + } +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 64eeaf91d7..dcb5f71610 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -31,6 +31,7 @@ import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -259,16 +260,30 @@ export class SessionsService implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. + * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, + conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }) const restored = this.selection.getSnapshot() - this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress) + const conversationEvents = rootCtx.get('conversationEvents') + const conversationViews = rootCtx.get('conversationViews') + const conversation = conversationRuntime ?? ( + conversationEvents === undefined || conversationViews === undefined + ? undefined + : { events: conversationEvents, views: conversationViews } + ) + this.manager = new SessionManager( + api, + restored.sessionId, + restored.subagentAddress, + conversation, + ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', subagentsByParent: {}, currentAddress: undefined, @@ -296,6 +311,25 @@ export class SessionsService implements ISessions { resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), }) this.currentProvideInfo = this.provideChannel.currentProvideInfo + let registryRebuildQueued = false + const scheduleRegistryRebuild = (): void => { + if (registryRebuildQueued) return + registryRebuildQueued = true + queueMicrotask(() => { + registryRebuildQueued = false + this.manager.rebuildConversationRegistry() + }) + } + if (conversation !== undefined) { + rootCtx.effect(() => { + const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild) + const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild) + return () => { + disposeEvents() + disposeViews() + } + }, 'sessions: conversation registry rebuild') + } rootCtx.reflect.provide('sessions', this, undefined) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0813c02711..a533f9dd6f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,7 +2,6 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, @@ -12,27 +11,23 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' +import { ConversationNodeAssembler } from './conversation-assembler.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' +import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts' import type { - ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, - OpenState, PromptError, QueuedMessage, RunningToolCall, + ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError, } from './conversation.ts' +import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' -import { TranscriptAdapter } from './transcript-adapter.ts' -import { displayFailureMessage } from './failure-display.ts' import { Notifier } from './notifier.ts' -import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' -import { ToolCallTree } from './tool-call-tree.ts' +import { SessionQueueMirror } from './queue-mirror.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 -// Browser bundles cannot value-import the host timeout library. This protocol -// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. -const MAX_RETRY_DELAY_MS = 2_147_483_647 - /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ @@ -54,24 +49,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore -} - -/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ -const QUEUE_PREVIEW_CHARS = 200 - -/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ -function queuePreviewOf(content: readonly ContentBlock[]): string { - const flat = content - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat -} - -/** Recover complete composer text only when editing cannot discard non-text blocks. */ -function queueTextOf(content: readonly ContentBlock[]): string | null { - if (!content.every(block => block.type === 'text')) return null - return content.map(block => block.text).join('') + /** Runtime registries used by this Session-owned Conversation assembler. */ + conversation?: ConversationRuntime } /** @@ -96,42 +75,13 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false - private readonly transcript = new TranscriptAdapter() - private partial: PartialAccumulator | null = null - private openCalls = new Map() - /** Last entered step per turn, folded from step/start for terminal error placement. */ - private lastStepByTurn = new Map() - /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. - * Derived from window events and rebuilt with partial/openCalls; the transcript is - * seq-monotonic, so a plain seq merge preserves event order. */ - private derivedNodes: ConversationNode[] = [] private pending = new Map() - // Revision counters preserve array identity when derived content 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 derivedRev = 0 - private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null - /** Exact turn timing retained from the raw window so presentation never - * infers elapsed time from transcript content. */ - private turnTimings = new Map() - private turnTimingsRev = 0 - private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null - /** Completed turn boundaries retained from the raw window so presentation - * actions never infer a safe fork point from transcript content alone. */ - private turnEnds = new Map() - private turnEndsRev = 0 - private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private queued: QueuedMessage[] = [] - private queueRev = 0 - private queueCache: { rev: number; value: QueuedMessage[] } | null = null - /** Window-derived child-call lifecycle and immutable tree projection. */ - private readonly toolCallTree = new ToolCallTree() + private readonly queueMirror = new SessionQueueMirror() + /** Session-owned business Context engine over the contiguous raw window. */ + private readonly conversation: ConversationNodeAssembler private running = false private address: SubagentAddress | undefined private parentAvailable = false @@ -141,8 +91,10 @@ export class Session implements SessionFace { * engaging edge of the phase machine (see ComposerPhase). */ private promptAttempted = false - /** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */ - private blankBit = false + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false + /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + private blankBit = true private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null @@ -167,9 +119,7 @@ export class Session implements SessionFace { readonly projections: ProjectionValueStore private snapshotCache: ConversationSnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) + private readonly notifier: Notifier /** * Agent-scoped cordis context, bound once by SessionsService when it * mints the scope (the client mirror of the host Agent's loopCtx). The @@ -192,6 +142,16 @@ export class Session implements SessionFace { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address this.parentAvailable = options.parentAvailable ?? false + this.conversation = options.conversation === undefined + ? new ConversationNodeAssembler( + { entries: () => [], fallbackEntry: () => undefined }, + { entries: () => [] }, + ) + : new ConversationNodeAssembler(options.conversation.events, options.conversation.views) + this.notifier = new Notifier(() => { + this.conversation.flush() + this.snapshotCache = this.buildSnapshot() + }) this.snapshotCache = this.buildSnapshot() } @@ -228,6 +188,7 @@ export class Session implements SessionFace { // visible on the session area's very first frame when a caller sends // ahead of navigation (first-send flow). this.promptAttempted = true + if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { @@ -375,6 +336,7 @@ export class Session implements SessionFace { const older = result.value.events if (older.length === 0) { this.hasMore = result.value.hasMore + this.conversation.prepend([], this.hasMore) return } const tail = older[older.length - 1] @@ -389,8 +351,7 @@ export class Session implements SessionFace { /* 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.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) - this.rebuildDerivedFromWindow() + this.conversation.prepend(older.map(conversationInput), this.hasMore) } catch (error) { console.error('[web-runtime] loadOlder failed:', error) } finally { @@ -461,15 +422,7 @@ export class Session implements SessionFace { return } case 'session/queue': { - this.queued = frame.items.map(item => ({ - id: item.id, - messageId: item.message.id, - placement: item.placement, - content: item.message.content, - preview: queuePreviewOf(item.message.content), - text: queueTextOf(item.message.content), - })) - this.queueRev++ + this.queueMirror.replace(frame.items) this.notifier.markDirty() return } @@ -479,11 +432,7 @@ export class Session implements SessionFace { // snapshot AFTER the subscribed frame on the same stream, so the // stale mirror clears here — race-free against onConnected/resync // timing (clearing there could wipe a baseline that already landed). - if (this.queued.length > 0) { - this.queued = [] - this.queueRev++ - this.notifier.markDirty() - } + if (this.queueMirror.reset()) this.notifier.markDirty() return } case 'approval/requested': { @@ -527,6 +476,7 @@ export class Session implements SessionFace { this.blankBit = false this.notifier.markDirty() } + if (running) this.firstPromptPendingTurn = false if (this.running === running) return this.running = running this.notifier.markDirty() @@ -590,6 +540,11 @@ export class Session implements SessionFace { /** No-op because session instances remain resident. */ dispose(): void {} + /** Rebuild the current window after a low-frequency Definition or view registration change. */ + rebuildConversationRegistry(): void { + this.scheduleConversation(this.conversation.rebuildRegistry()) + } + // ---- 私有 ---- /** Requested-frame arrival: the wait enters the pending map under its own key. */ @@ -651,8 +606,8 @@ export class Session implements SessionFace { this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views) - this.rebuildDerivedFromWindow() + if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false + this.conversation.replaceWindow(entries.map(conversationInput), hasMore) if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer this.liveBuffer = [] @@ -661,32 +616,22 @@ export class Session implements SessionFace { } /** Seq-guarded append shared by stitching and the open-state live path. */ - private appendLive(event: SessionEvent, view?: ToolEventView): void { + private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication { const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop + if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop this.events.push(event) this.views.push(view) - this.transcript.append(event, view) - this.handoffPendingSteering(event) - this.applyEventSideEffects(event, view) - } - - /** Retire the first matching live steering occurrence when its durable message takes over. */ - private handoffPendingSteering(event: SessionEvent): void { - if (event.type !== 'user/message') return - const message = event.data - const index = this.queued.findIndex(item => - item.placement === 'steering' && item.messageId === message.id) - if (index === -1) return - this.queued = this.queued.filter((_item, candidate) => candidate !== index) - this.queueRev++ + if (event.type === 'turn/start') this.firstPromptPendingTurn = false + const queueChanged = this.queueMirror.acceptDurable(event) + const publication = this.conversation.append({ event, view }) + return queueChanged ? 'immediate' : publication } /** 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). The window stays one contiguous - * raw range, which is what lets the transcript render every event between its ends and lets a - * compaction checkpoint find its cited summary event. */ + * raw range, which lets Conversation Definitions correlate every recorded event between its + * ends and lets a compaction checkpoint resolve its cited summary event. */ private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) @@ -699,12 +644,13 @@ export class Session implements SessionFace { void this.repairGap() return } - this.appendLive(event, view) - if (event.type === 'assistant/chunk') { - if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty() - return - } - this.notifier.markDirty() + this.scheduleConversation(this.appendLive(event, view)) + } + + /** Route assembler cadence into the Session's existing microtask/RAF notifier. */ + private scheduleConversation(publication: ConversationPublication): void { + if (publication === 'immediate') this.notifier.markDirty() + else if (publication === 'animation-frame') this.notifier.markFrameDirty() } /** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared @@ -728,238 +674,35 @@ export class Session implements SessionFace { } } - /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk/retry projection and openCalls add-remove. */ - private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { - const eventType = event.type as string - if (eventType === 'llm/retry') { - const data = parseRetryEventData(event.data) - if (data === null) { - console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) - return - } - if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { - this.partial = null - } - this.derivedNodes.push({ - kind: 'model-retry', - seq: event.seq, - time: event.time, - retryState: 'scheduled', - ...data, - }) - this.derivedRev++ - return - } - // These lifecycle events are declared by a host-only plugin whose Context - // types cannot enter the client program. ToolCallTree owns their structural - // wire narrowing, pairing, and nested snapshot projection. - if (this.toolCallTree.apply(event)) return - switch (event.type) { - case 'turn/start': - this.lastStepByTurn.set(event.data.turn, 0) - this.turnTimings.set(event.data.turn, { startTime: event.time }) - this.turnTimingsRev++ - return - case 'step/start': - this.lastStepByTurn.set(event.data.turn, event.data.step) - return - case 'assistant/chunk': { - const { turn, step, chunk } = event.data - this.settleScheduledRetry('started', turn) - 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, time: event.time, - callView: view?.for === 'call' ? view.view : null, - subCalls: [], - }) - this.callsRev++ - return - } - case 'tool/result': { - if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++ - return - } - case 'turn/end': { - const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0 - const timing = this.turnTimings.get(event.data.turn) - if (timing !== undefined) { - this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time }) - this.turnTimingsRev++ - } - this.turnEnds.set(event.data.turn, event.seq) - this.turnEndsRev++ - if (event.data.reason.kind === 'aborted') { - this.settleScheduledRetry('cancelled', event.data.turn) - } - if ( - event.data.reason.kind === 'error' - && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn) - ) { - const failure = event.data.reason.error - this.derivedNodes.push({ - kind: 'turn-error', - seq: event.seq, - time: event.time, - turn: event.data.turn, - step: lastStep, - message: displayFailureMessage(failure), - code: failure.code, - }) - this.derivedRev++ - } - if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn) - // 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.derivedNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, time: event.time, - turn: this.partial.turn, step: this.partial.step, - blocks, interrupted: true, - }) - this.derivedRev++ - } - 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.derivedNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, - callId, - call: { name: call.name, argsRaw: call.argsRaw }, - callTime: call.time, - content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, resultView: null, subCalls: [], - }) - this.derivedRev++ - } - this.lastStepByTurn.delete(event.data.turn) - return - } - default: - return - } - } - - /** - * Settle the newest scheduled retry, optionally restricted to its failed turn. - * @param retryState - next client projection state to publish. - * @param turn - failed turn required for cancellation; omitted for the next retry turn start. - */ - private settleScheduledRetry( - retryState: Exclude, - turn?: number, - ): void { - const index = this.derivedNodes.findLastIndex(node => - node.kind === 'model-retry' - && node.retryState === 'scheduled' - && (turn === undefined || node.turn === turn)) - if (index < 0) return - const node = this.derivedNodes[index] - /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ - if (node?.kind !== 'model-retry') return - this.derivedNodes[index] = { ...node, retryState } - this.derivedRev++ - } - - /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps - * paging/stitching consistent, and makes live handling and history replay converge on the same - * retry notices and interrupted nodes. */ - private rebuildDerivedFromWindow(): void { - this.partial = null - this.openCalls.clear() - this.lastStepByTurn.clear() - this.callsRev++ - this.derivedNodes = [] - this.derivedRev++ - this.turnTimings = new Map() - this.turnTimingsRev++ - this.turnEnds = new Map() - this.turnEndsRev++ - this.toolCallTree.reset() - 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 projected = this.transcript.nodes() - // Derived interruption nodes ride fractional seqs while retry notices keep their event seq. - // The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the - // merge on (projected reference, derivedRev) to retain identity across unrelated swaps. - let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) { - nodes = this.nodesCache.value - } else { - nodes = this.derivedNodes.length === 0 - ? projected - : [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes } - } - if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { - this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } - } - if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) { - this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) } - } - if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { - this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } - } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } - if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { - this.queueCache = { rev: this.queueRev, value: this.queued } - } - const partial = this.partial?.toPartial() ?? null + const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT + const legacy = chat.legacy return { sessionId: this.sessionId, - nodes: this.toolCallTree.projectNodes(nodes), - turnTimings: this.turnTimingsCache.value, - turnEnds: this.turnEndsCache.value, - partial, - runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value), + chat, + nodes: legacy.nodes, + turnTimings: legacy.turnTimings, + turnEnds: legacy.turnEnds, + partial: legacy.partial, + runningCalls: legacy.runningCalls, pending: this.pendingCache.value, - queue: this.queueCache.value, + queue: this.queueMirror.snapshot(), running: this.running, subagent: this.address === undefined ? null : { address: this.address, parentAvailable: this.parentAvailable }, composerPhase: derivePhase( - // Command lifecycle nodes are not conversation: running /permission - // or /plan on a fresh session keeps the hero (the client mirror of - // the host's no-turn sessionBlank predicate). - nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, + (!this.blankBit && !this.firstPromptPendingTurn) + || this.running + || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -985,67 +728,18 @@ export class Session implements SessionFace { } } -/** Validate the plugin-owned payload at the session-event wire boundary. */ -function parseRetryEventData(value: unknown): LlmRetryEventData | null { - if (value === null || typeof value !== 'object') return null - const data = value as Record - const failure = data.failure - if (failure === null || typeof failure !== 'object') return null - const failureData = failure as Record - if (!nonNegativeSafeInteger(data.turn) - || !nonNegativeSafeInteger(data.step) - || typeof data.provider !== 'string' - || data.provider.length === 0 - || typeof data.policyKey !== 'string' - || data.policyKey.length === 0 - || !positiveSafeInteger(data.retry) - || typeof data.delayMs !== 'number' - || !Number.isFinite(data.delayMs) - || data.delayMs < 0 - || data.delayMs > MAX_RETRY_DELAY_MS - || typeof failureData.message !== 'string' - || failureData.message.length === 0 - || typeof failureData.code !== 'string' - || failureData.code.length === 0) return null - if (data.mode === 'normal') { - if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null - } else if (data.mode === 'always') { - if ('maxRetries' in data) return null - } else { - return null - } - if (failureData.status !== undefined - && (typeof failureData.status !== 'number' - || !Number.isInteger(failureData.status) - || failureData.status < 100 - || failureData.status > 599)) return null - if (failureData.providerRetryAfterMs !== undefined - && (typeof failureData.providerRetryAfterMs !== 'number' - || !Number.isFinite(failureData.providerRetryAfterMs) - || failureData.providerRetryAfterMs <= 0)) return null - if (failureData.requestId !== undefined - && (typeof failureData.requestId !== 'string' - || failureData.requestId.length === 0)) return null - return data as unknown as LlmRetryEventData -} - -function nonNegativeSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -} - -function positiveSafeInteger(value: unknown): value is number { - return nonNegativeSafeInteger(value) && value > 0 +/** Convert one wire history row into the assembler's transport-neutral input. */ +function conversationInput(entry: HistoryEntry): ConversationEventInput { + return { event: entry.event, view: entry.view } } /** * The composerPhase judgment — the single site that knows the predicate - * (consumers switch on the result, never re-derive). Monotone per session - * object: `hasContent` only grows within a window and `promptAttempted` is - * sticky, so blank → engaging → active never steps back; a failed first - * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (non-command nodes, - * partial, running turn, pending waits; command lifecycle rows alone keep - * the session blank). + * (consumers switch on the result, never re-derive). A failed first prompt + * stays engaging until an authoritative accepted-turn, running, or pending + * signal arrives (retry semantics — see ComposerPhase). + * @param hasContent - authoritative non-blank activity beyond a pending first + * prompt, a running turn, or a pending interaction. * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts deleted file mode 100644 index 78090ce87f..0000000000 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ /dev/null @@ -1,409 +0,0 @@ -// TranscriptAdapter: the human transcript projected from the raw event window -// in LOG order. The model-visible surface deliberately shadows replaced ranges, -// so it is the wrong source for conversation a reader already saw; this adapter -// keeps every append-origin event at its own log position and contributes one -// marker node per landed compaction checkpoint. Node order is therefore -// seq-monotonic by construction — no surface fold, no padding sentinels, no -// seq === index assertion to satisfy, and no degradation branch. - -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 { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -// Cordis-free leaf subpath (the dsh-commands/brand shape): the Service Definition's -// declaration of the checkpoint source, reachable as a TYPE from this program. -// The package ROOT is not — it reaches dsh-session's root, whose Context merge -// declares the HOST `sessions: SessionStore` against this program's -// `sessions: ISessions` (TS2717, the one-program-per-side rule in -// docs/development.md). -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' -import { toAssistantBlocks } from './conversation.ts' -import { contextForm, contextProvenance } from './context-provenance.ts' -import { SteeringHistory } from './steering-history.ts' -import type { AssistantStepMetadata } from './assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' - -/** - * The compaction capability's checkpoint plugin, pinned to the Service Definition's declaration - * at COMPILE time: renaming it there fails this annotation (`TS2322`). The - * import stays type-only because a value import would fail the client purity - * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are - * forbidden in a browser bundle — while an erased type never reaches it. - */ -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' - -/** In-window tool/call index entry used to materialize result cards. */ -interface CallIndexEntry { - name: string - argsRaw: string - turn: number - step: number - /** Unix epoch ms of the tool/call event. */ - time: number - /** Wire view riding the tool/call (envelope-level; never inside the event). */ - callView: ToolCallView | null -} - -/** One event -> UI node (pure function; the ten-variant ConversationNode union). */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - steering: boolean, - stepTimings: ReadonlyMap, -): ConversationNode { - switch (event.type) { - case 'user/message': { - // Injected context (plugin/goal/skill-invocation source) folds to a - // context node, not a user message; only a direct human prompt is a - // user node. A compaction checkpoint never reaches here - // (isCompactCheckpoint routes it away). - if (event.data.source.kind !== 'user') { - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - provenance: contextProvenance(event.data.source), - form: contextForm(event.data.source), - } - } - if (steering) { - return { - kind: 'steering', messageId: event.data.id, - seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - return { - kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - case 'assistant/message': - return { - kind: 'assistant', seq: event.seq, time: event.time, - turn: event.data.turn, step: event.data.step, - blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), - } - case 'tool/result': { - const result = event.data.message.content[0] - const callId = String(event.data.message.source.callId) - const call = callIndex.get(callId) - return { - kind: 'tool-result', seq: event.seq, time: event.time, - callId, - call: call ? { name: call.name, argsRaw: call.argsRaw } : null, - callTime: call?.time ?? null, - content: result.content, isError: result.isError === true, - ...(event.data.error !== undefined ? { error: event.data.error } : {}), - meta: event.data.meta, - callView: call?.callView ?? null, - resultView, - subCalls: [], - } - } - /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types - can be append-origin, and each has a case above; reachable only if core - adds an eligible type. */ - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} - -/** - * Whether an event is a landed compaction checkpoint — all three conditions, - * matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the - * compaction seam's checkpoint plugin source, that REPLACED a surface range. A - * plugin-sourced `user/message` that appends is injected context (a - * session-reference card), not a compaction; a replacement `tool/result` is an - * in-place prune and a replacement `assistant/message` a generic rewrite, and - * both mark no boundary in the conversation. - * @param event - the raw window event. - * @returns true when the event compacted a surface range. - */ -function isCompactCheckpoint(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const source = event.data.source - return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN - && isReplacementSurfaceEvent(event) -} - -/** Whether an event contributes a node to the human transcript. */ -function isTranscriptEvent(event: SessionEvent): boolean { - return isAppendSurfaceEvent(event) || isCompactCheckpoint(event) -} - -/** - * Concatenated text of a `compact/summary` payload, or null when it carries no - * usable text. The payload is a `ContentBlock[]` whose union is - * merge-extensible, so a non-text block is skipped rather than discarding the - * text beside it; a payload with no text block at all falls to null through the - * empty check. - */ -function compactSummaryText(event: SessionEvent): string | null { - const summary = (event.data as unknown as { summary?: unknown }).summary - if (!Array.isArray(summary)) return null - let text = '' - for (const block of summary as readonly unknown[]) { - const candidate = block as { type?: unknown; text?: unknown } - if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue - text += candidate.text - } - return text.trim() === '' ? null : text -} - -interface CompactSummaryDetails { - readonly summary: string | null - readonly shadowedItemCount: number | null - readonly shadowedTokenCount: number | null -} - -/** Recover human-facing summary material from one structurally narrowed wire event. */ -function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails { - const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown } - const shadowedSeqs = data.shadowedSeqs - const tokenCount = data.shadowedTokenCount - return { - summary: compactSummaryText(event), - shadowedItemCount: Array.isArray(shadowedSeqs) - && shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0) - ? shadowedSeqs.length - : null, - shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 - ? tokenCount as number - : null, - } -} - -/** - * One landed checkpoint -> the human-facing compaction marker. The summary text - * comes from the checkpoint's cited `compact/summary` event (`sourceEventSeqs` names the - * `compact/summary` event), never from the framed checkpoint payload, which is - * an instruction envelope written for the model. A window cut that left the - * summary event outside soft-falls to `summary: null` (a non-expandable marker), - * the same posture as a call-less tool result. - */ -function materializeCompaction( - checkpoint: SessionEvent, - eventIndex: ReadonlyMap, -): CompactionSummaryNode { - const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs - let summary: string | null = null - let summaryEventSeq: number | null = null - let shadowedItemCount: number | null = null - let shadowedTokenCount: number | null = null - for (const seq of sources ?? []) { - const candidate = eventIndex.get(seq) - if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - const details = compactSummaryDetails(candidate) - summary = details.summary - summaryEventSeq = candidate.seq - shadowedItemCount = details.shadowedItemCount - shadowedTokenCount = details.shadowedTokenCount - break - } - return { - kind: 'compaction', - seq: checkpoint.seq, - time: checkpoint.time, - summary, - summaryEventSeq, - shadowedItemCount, - shadowedTokenCount, - } -} - -/** Log-ordered human transcript over a paged raw event window (never consults surface order). */ -export class TranscriptAdapter { - /** Window events by seq, used to find the summary event cited by a checkpoint. */ - private eventIndex = new Map() - /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ - private projected: ConversationNode[] = [] - private callIdx = new Map() - /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ - private stepTimings = new Map() - /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ - private resultViews = new Map() - /** Durable inbox replay used to distinguish next-step human input from queued prompts. */ - private readonly steeringHistory = new SteeringHistory() - /** - * Command lifecycle nodes by commandId (insertion = run order). The - * `command/run`/`command/done` pair is log-only, so it is not a surface - * event and never joins the transcript projection; this index folds the pair - * (done settles its run's node in place) and nodes() merges the products in - * by seq. Window cuts soft-fall like tool pairs: a done with no in-window - * run still builds a node. - */ - private commandIdx = new Map() - /** Projection revision, bumped only when a transcript node or a command node actually - * changed, keying the nodes() result cache: an unchanged projection returns the previous - * ARRAY reference, not just cached elements — the snapshot's reference-stability contract - * (§A.9.4) starts here, and a chunk storm bumps nothing at all. */ - private rev = 0 - private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - - /** - * Window rebuild (after open/resync/page prepend): re-index the raw window - * and re-project the transcript. - * @param events - the new window contents (seq-ascending). - * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). - */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { - this.rev++ - this.eventIndex = new Map() - this.callIdx = new Map() - this.resultViews.clear() - this.commandIdx = new Map() - this.steeringHistory.reset() - const steeringSeqs = new Set() - this.stepTimings = new Map() - for (let i = 0; i < events.length; i++) { - const event = events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event === undefined) continue - this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) - this.indexCommand(event) - if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) - indexAssistantStepTiming(this.stepTimings, event) - } - // Indexes first, then project: a tool/result materializes against the - // complete call index, and a checkpoint against the complete event index. - const projected: ConversationNode[] = [] - for (const event of events) { - if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq))) - } - this.projected = projected - } - - /** - * Tail append (live session/event): index the event and, when it belongs to - * the transcript, extend the projection by one copy-on-write node so a - * published array never mutates. An event that changes no node (a chunk - * storm) bumps no revision, so nodes() keeps returning the same array - * reference. - * @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.eventIndex.set(event.seq, event) - this.indexCall(event, view) - const steering = this.steeringHistory.apply(event) - indexAssistantStepTiming(this.stepTimings, event) - if (this.indexCommand(event)) this.rev++ - if (!isTranscriptEvent(event)) return - this.projected = [...this.projected, this.materialize(event, steering)] - this.rev++ - } - - /** - * The current transcript node array. Same revision -> same array reference - * (memo boundary); node objects are materialized once, so an unchanged node - * keeps its identity across appends. - * @returns transcript nodes in log order, command nodes merged in by seq. - */ - nodes(): readonly ConversationNode[] { - if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value - // Command nodes fold outside the transcript (log-only events); merge by - // seq. Both inputs are seq-ascending (log order and run-index insertion - // order are the same order), so one linear merge keeps flow order. - let nodes = this.projected - if (this.commandIdx.size > 0) { - nodes = [] - const commands = [...this.commandIdx.values()] - let next = 0 - for (const node of this.projected) { - for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) { - nodes.push(cmd) - } - nodes.push(node) - } - for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd) - } - this.nodesResult = { rev: this.rev, value: nodes } - return nodes - } - - /** Materialize one transcript event against the complete current indexes. */ - private materialize(event: SessionEvent, steering: boolean): ConversationNode { - return isCompactCheckpoint(event) - ? materializeCompaction(event, this.eventIndex) - : materializeNode( - event, - this.callIdx, - this.resultViews.get(event.seq) ?? null, - steering, - this.stepTimings, - ) - } - - /** - * Fold one command lifecycle event into its node (run mints, done settles in - * place; done-only soft-falls). - * @returns whether the command index changed, so callers can bump the revision. - */ - private indexCommand(event: SessionEvent): boolean { - // Log-only plugin events: the host-side dsh-commands declaration cannot - // enter the client program, so this wire consumer narrows structurally - // (the same posture as tool/code-dispatch in session.ts). - if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, - }) - return true - } - if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - const run = this.commandIdx.get(data.commandId) - const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number - : undefined - const outcome = { - kind: data.kind, - ...data.text === undefined ? {} : { text: data.text }, - ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, - } - if (run === undefined) { - // Cross-window cut: the run page fell out of the window — build the - // node from the done alone (same soft-fall as a call-less tool result). - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: null, args: null, outcome, - }) - return true - } - // Settle in place: a fresh node object (published references stay immutable). - this.commandIdx.set(data.commandId, { ...run, outcome }) - return true - } - - 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, - time: event.time, - 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). - } -} diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index a30adb4dd6..e3ad848e05 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -320,7 +320,7 @@ export class SlotsService extends Service { const dispose = (this._core as unknown as ErasedCore).register(erased, component) if (store !== undefined) { // Register succeeded, so the target's spec is on the ledger. - const scope = (this._core.specDynamic(options.name) as SlotSpec).scope + const scope = (this._core.specDynamic(options.name) as SlotSpec).scope this._acquire(store, scope) } let disposed = false diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7d7d9a6c34..4ed6ecf519 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -101,15 +101,40 @@ export interface SlotEntryDef { kind: SlotKind scope: SlotScope owner?: object + /** + * Optional keyed-entry prop table. A keyed registration contributes one + * literal key and receives the corresponding prop share; ordinary owner + * props remain common to every key. + */ + keyProps?: Record + /** + * Optional opaque context carried by one renderSlot occurrence. Only + * function-valued members of the slot-level injected hooks compartment + * receive it; the slot machinery never interprets the value. + */ + hookContext?: unknown + /** + * Optional Slot-level inject face supplied by the parent registration's + * child declaration. Every registered entry receives its bound component + * face; child registrants do not own or replace this common capability. + */ + inject?: object } /** * Runtime dispatch spec for one slot, recorded from a register call's * `children` value. The literal is compile-time checked against the SlotMap - * entry (`SlotSpec` in {@link ChildrenDecl}), so type and value - * are declared at one point and validate each other. + * entry (`SlotSpec` in {@link ChildrenDecl}), so kind, scope, and + * any common inject face are declared at one point and validate each other. */ -export interface SlotSpec { kind: E['kind']; scope: E['scope'] } +export type SlotSpec = { + kind: E['kind'] + scope: E['scope'] +} & ('inject' extends keyof E + ? E extends { inject: infer Injected extends object } + ? { inject: Injected } + : { inject?: object } + : { inject?: never }) /** * Child-slot declaration table for register(): keys are the declared (and @@ -123,6 +148,30 @@ export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec = SlotMap[K] extends { owner: infer O extends object } ? O : object +/** Registration/dispatch key domain of one keyed slot. */ +export type EntryKeyOf = + SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? keyof P & string + : string + +/** Key-dependent props supplied by the owner at one keyed dispatch site. */ +export type KeyPropsOf< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, +> = SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? EntryKey extends keyof P + ? P[EntryKey] extends object ? P[EntryKey] : never + : never + : object + +/** Opaque per-render occurrence context declared by one slot. */ +export type HookContextOf = + SlotMap[K] extends { hookContext: infer Context } ? Context : never + +/** Common render-occurrence inject face declared by one slot. */ +export type SlotInjectOf = + SlotMap[K] extends { inject: infer Injected extends object } ? Injected : object + /** Scope axis of a slot key's SlotMap entry. */ export type ScopeOf = SlotMap[K]['scope'] @@ -159,15 +208,26 @@ export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? * Runtime props share for a slot key: owner share (parent's renderSlot call * site) + session standard kit (session scope only) + the global seat. */ -export type PropsRuntime = +export type PropsRuntime< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf = EntryKeyOf, +> = OwnerOf & + KeyPropsOf & + SlotInjectFace> & (ScopeOf extends 'session' ? SessionStandardProps : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps : object) & GlobalStandardProps -/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ -export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } +/** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */ +export interface RenderOpts { + entryKey?: EntryKey + only?: string + fallback?: ReactNode + /** Type-erased runtime seat; PropsRenderSlots narrows or removes it per slot declaration. */ + hookContext?: unknown +} /** renderSlotChain dispatch options. */ export interface ChainRenderOpts { @@ -200,6 +260,40 @@ export type ChainSelect = (owner: O) => M | null export type ChainKeysOf = S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never +/** Keys in a render share whose dispatch occurrence requires hookContext. */ +type ContextualKeysOf = + S extends unknown ? (SlotMap[S] extends { hookContext: unknown } ? S : never) : never + +/** Keys in a render share with the ordinary optional options bag. */ +type OrdinaryKeysOf = Exclude> + +/** + * Plain and contextual child dispatch signatures. Keeping them as separate + * call signatures preserves ordinary renderSlot assignability while making a + * declared hookContext mandatory only for the Slot keys that need it. + */ +type RenderSlotFn = + ([ContextualKeysOf] extends [never] ? object : { + < + K extends ContextualKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts: RenderOpts & { hookContext: HookContextOf }, + ): ReactNode + }) & + ([OrdinaryKeysOf] extends [never] ? object : { + < + K extends OrdinaryKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts?: Omit, 'hookContext'>, + ): ReactNode + }) + /** * Chain matched share: a chain-slot component receives its selector's * non-null result as the framework-injected `matched` prop; other kinds add @@ -248,7 +342,7 @@ export type PropsRenderSlots = { * @param opts - kind dispatch options. * @returns rendered node(s). */ - renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode + renderSlot: RenderSlotFn>> readonly __renders?: ((key: S) => void) | undefined } & ([ChainKeysOf] extends [never] ? object : { /** @@ -277,19 +371,53 @@ export type SlotComponent

= (props: P) => ReactNode /** * Registrant hooks compartment: bare observable sources (getSnapshot + - * subscribe pairs) supplied under the reserved `hooks` key of an inject - * face. The registrant-private twin of the `sessions.provide` hooks - * compartment: the renderer binds each source into a `use` selector - * hook, so the sources never reach the component and plugin-private reactive - * facts ride the same subscription machinery as the standard kit instead of - * hand-rolled component subscriptions. + * subscribe pairs) supplied under the reserved `hooks` key of an entry's + * inject face. These retain the original source-to-selector binding and do + * not participate in render-occurrence context. */ export type HooksSources = Record> +/** Framework-owned props visible while a slot-level contextual Hook is bound. */ +export type StandardPropsOf = + (ScopeOf extends 'session' ? SessionStandardProps + : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps + : object) & + GlobalStandardProps + +/** + * One function-valued slot-level inject.hooks member. The factory is pure and + * returns the actual custom Hook; it must not invoke a Hook while being bound. + */ +export type SlotHookFactory< + K extends keyof SlotMap & string, + Hook extends (...args: never[]) => unknown, +> = ( + standard: StandardPropsOf, + hookContext: HookContextOf, +) => Hook + +/** Component-side Hook produced from one slot-level inject.hooks member. */ +type BoundHookOf = + Definition extends HostObservable + ? SnapshotSelectorHook + : Definition extends (...args: never[]) => infer Hook + ? Hook extends (...args: never[]) => unknown ? Hook : never + : never + /** * Selector-hook share synthesized from a hooks compartment: each source * `name` becomes a `use` selector hook over its snapshot type. */ +export type PropsSlotHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + BoundHookOf +} + +/** Component-side view of a slot dispatcher's common inject face. */ +export type SlotInjectFace = + I extends { hooks: infer HS extends object } ? Omit & PropsSlotHooks : I + +/** Selector-hook share synthesized from an entry inject hooks compartment. */ export type PropsHooks = { [N in keyof HS & string as `use${Capitalize}`]: SnapshotSelectorHook ? T : never> @@ -313,12 +441,13 @@ export type InjectFace = */ export type ComposedProps< K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -345,12 +474,16 @@ export type InjectParams = export type SlotLabel = string | (() => string) /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ -export type KindOptions = - E['kind'] extends 'keyed' ? { key: string } - : E['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } - : E['kind'] extends 'chain' ? { +export type KindOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + M = never, +> = + SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey } + : SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } + : SlotMap[K]['kind'] extends 'chain' ? { /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ - select: ChainSelect + select: ChainSelect /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ priority?: number } @@ -372,7 +505,14 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + D extends ChildrenDecl, + H, + M = never, + N = undefined, +> = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ @@ -388,7 +528,7 @@ type BaseOptions +} & KindOptions /** * One stored registration, as recorded by the core and read by the render @@ -528,15 +668,16 @@ export class SlotCore { * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -552,15 +693,16 @@ export class SlotCore { register< K extends keyof SlotMap & string, I extends object, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void /* jscpd:ignore-end */ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2a676fa22a..20f6a68eb1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -87,11 +87,13 @@ export interface SessionProvideInfo extends SessionMaybeProvideInfo { hooks: Record> } -/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */ +/** renderSlot dispatch options at the machinery level. */ export interface RenderOpts { entryKey?: string only?: string fallback?: ReactNode + /** Opaque occurrence context consumed only by function-valued injected Hooks. */ + hookContext?: unknown } /** Host surface the runtime SlotsService presents to the installed renderer. */ diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index f7a3c32e93..69d37f35b4 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -2,7 +2,7 @@ * React renderer for declarative slots. Per-entry bindings enforce child * authorization, and entry boundaries contain registrant failures. */ -import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' +import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, @@ -16,6 +16,14 @@ import { type InjectedProps = Record +type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown +type SlotHookFactories = Readonly> + +interface BoundSlotInject { + readonly props: InjectedProps + readonly slotHookFactories?: SlotHookFactories | undefined +} + type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode @@ -89,9 +97,11 @@ const rootInjectCache = new WeakMap() const sessionInjectCache = new WeakMap>() const sessionMaybeInjectCache = new WeakMap>() +const EMPTY_INJECTED_PROPS: InjectedProps = {} + function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps { const inject = entry.inject - if (!inject) return {} + if (!inject) return EMPTY_INJECTED_PROPS // Declaration-derived positional arguments: sessionId for session scope, // baked actions when a store is declared. const args: unknown[] = [] @@ -101,11 +111,8 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined } /** - * Bind an inject face's reserved `hooks` compartment (bare observable - * sources, see HooksSources) into `use` selector hooks — the - * registrant-private twin of the provide-bundle binding in standardKit. - * Runs once per cached inject result; hook identity rides observableHook's - * per-source cache. + * Normalize one entry-owned inject face on its existing cache axis. Its hooks + * compartment remains the original Observable-only contract. */ function bindInjectHooks(face: InjectedProps): InjectedProps { const sources = face['hooks'] @@ -119,6 +126,53 @@ function bindInjectHooks(face: InjectedProps): InjectedProps { return bound } +const slotInjectCache = new WeakMap() +const EMPTY_SLOT_INJECT: BoundSlotInject = { props: EMPTY_INJECTED_PROPS } + +/** Normalize one dispatcher-owned inject face by its stable object identity. */ +function cachedSlotInject(face: object | undefined): BoundSlotInject { + if (face === undefined) return EMPTY_SLOT_INJECT + let bound = slotInjectCache.get(face) + if (bound !== undefined) return bound + const definitions = (face as InjectedProps)['hooks'] + if (definitions === undefined) { + bound = { props: face as InjectedProps } + slotInjectCache.set(face, bound) + return bound + } + const { hooks: _hooks, ...rest } = face as InjectedProps + const props: InjectedProps = rest + let factories: Record | undefined + for (const [name, definition] of Object.entries(definitions as Record)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (typeof definition === 'function') { + factories ??= {} + factories[name] = definition as SlotHookFactory + } else { + props[hookName] = observableHook(definition as HostObservable) + } + } + bound = factories === undefined + ? { props } + : { props, slotHookFactories: factories } + slotInjectCache.set(face, bound) + return bound +} + +/** Bind deferred slot-level factories for one stable renderSlot occurrence. */ +function bindSlotHookFactories( + factories: SlotHookFactories, + standard: InjectedProps, + hookContext: unknown, +): InjectedProps { + const hooks: InjectedProps = {} + for (const [name, factory] of Object.entries(factories)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + hooks[hookName] = factory(standard, hookContext) + } + return hooks +} + function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { let props = rootInjectCache.get(entry) if (!props) { @@ -270,6 +324,54 @@ class SlotErrorBoundary extends Component< } } +interface StandardPropsCache { + readonly root: InjectedProps + readonly session: WeakMap + readonly sessionMaybe: WeakMap +} + +const standardPropsCache = new WeakMap() + +/** Stable official-props object used by contextual Hook factories. */ +function standardProps( + host: SlotRendererHost, + scope: SlotScope, + info: SessionMaybeProvideInfo | undefined, +): InjectedProps { + let cache = standardPropsCache.get(host) + if (cache === undefined) { + cache = { + root: { + useSessions: observableHook(host.sessions.list), + useWorkspaces: observableHook(host.workspaces.list), + }, + session: new WeakMap(), + sessionMaybe: new WeakMap(), + } + standardPropsCache.set(host, cache) + } + if (scope === 'root') return cache.root + if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`) + const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe + let standard = byInfo.get(info) + if (standard !== undefined) return standard + standard = { ...cache.root } + for (const [name, source] of Object.entries(info.hooks)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (scope === 'session-maybe') { + standard[hookName] = maybeObservableHook(source) + } else { + if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) + standard[hookName] = observableHook(source) + } + } + Object.assign(standard, info.props) + standard['sessionId'] = info.sessionId + standard['useProjection'] = projectionHook(info) + byInfo.set(info, standard) + return standard +} + /** * Standard-kit synthesis shared by both scope branches: the global * useSessions/useWorkspaces hooks, the per-session provide bundle (every @@ -289,28 +391,11 @@ function standardKit( info: SessionMaybeProvideInfo | undefined, ): { kit: InjectedProps + standard: InjectedProps actions: object | undefined } { - const kit: InjectedProps = { - useSessions: observableHook(host.sessions.list), - useWorkspaces: observableHook(host.workspaces.list), - } - if (scope !== 'root' && info !== undefined) { - for (const [name, source] of Object.entries(info.hooks)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` - if (scope === 'session-maybe') { - kit[hookName] = maybeObservableHook(source) - } else { - if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) - kit[hookName] = observableHook(source) - } - } - Object.assign(kit, info.props) - kit['sessionId'] = info.sessionId - // The useProjection seat (fifth framework hook): key-addressed cell - // reader, bound per provide bundle (cached by info identity). - kit['useProjection'] = projectionHook(info) - } + const standard = standardProps(host, scope, info) + const kit: InjectedProps = { ...standard } if (entry.locale !== undefined) { const face = host.locale // Loud assembly failure: locale is immediately-tier infrastructure; a @@ -344,38 +429,98 @@ function standardKit( kit['SessionProvider'] = SessionProvider } } - return { kit, actions: store?.actions } + return { kit, standard, actions: store?.actions } } /** - * One rendered entry: standard kit + cached inject + owner props (owner - * wins). The kit and injected shares are erased at the render boundary — the - * registration contract already proved the composed type — so each Entry renders - * through a props-widened view of the component (the design-budgeted - * composition point, one per scope branch). + * One rendered entry: standard kit + cached entry inject + common slot inject + * + owner props (owner wins). The shares are erased at this render boundary; + * the registration and renderSlot seams already proved their contracts. */ -function SessionEntry({ entry, ownerProps, info }: { +function ContextualEntry({ + slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, +}: { + slotKey: string + Comp: FC + kit: InjectedProps + standard: InjectedProps + injected: InjectedProps + slotInjected: BoundSlotInject & { readonly slotHookFactories: SlotHookFactories } + ownerProps: object + hookContext: unknown + hasHookContext: boolean +}) { + const contextual = useMemo( + () => { + if (!hasHookContext) { + throw new SlotAssemblyError(`slot '${slotKey}' has contextual injected Hooks but no hookContext`) + } + return bindSlotHookFactories(slotInjected.slotHookFactories, standard, hookContext) + }, + [hasHookContext, hookContext, slotInjected.slotHookFactories, slotKey, standard], + ) + return +} + +function renderEntry( + slotKey: string, + Comp: FC, + kit: InjectedProps, + standard: InjectedProps, + injected: InjectedProps, + slotInjected: BoundSlotInject, + ownerProps: object, + hookContext: unknown, + hasHookContext: boolean, +): ReactNode { + if (slotInjected.slotHookFactories === undefined) { + return + } + return ( + + ) +} + +function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session', info) + const { kit, standard, actions } = standardKit(host, entry, 'session', info) const injected = cachedSessionInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function SessionMaybeEntryBody({ entry, ownerProps, info }: { +function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionMaybeProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session-maybe', info) + const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info) const injected = cachedSessionMaybeInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } /** @@ -391,7 +536,14 @@ function SessionMaybeEntryBody({ entry, ownerProps, info }: { * that must SURVIVE a switch belongs in session-bound sources (machine, * store, hooks) — the existing layering rule, now load-bearing. */ -function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const info = useSessionMaybeProvideInfo() // The child key is an incarnation counter, NOT the session id: adoption // must keep the key constant across undefined → first id. Bookkeeping @@ -416,7 +568,18 @@ function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerPro epoch += 1 setState({ adopted, epoch }) } - return + return ( + + ) } /** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */ @@ -429,24 +592,42 @@ interface MaybeIncarnation { const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 } -function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'root', undefined) + const { kit, standard, actions } = standardKit(host, entry, 'root', undefined) const injected = cachedRootInject(entry, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function StrictSessionEntry({ slotKey, entry, ownerProps }: { +function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: { slotKey: string entry: StoredEntry ownerProps: object + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const info = useSessionMaybeProvideInfo() if (info.sessionId === undefined) return null return ( - + ) } @@ -478,31 +659,62 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { // An absent strict overlay chain follows its ordinary empty-election path, // preserving the Fragment/fallback-wrapper shape across session arrival. const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey) + const slotInjected = cachedSlotInject(spec.inject) // The boundary must wrap the Entry ELEMENT, not live inside it: inject // factories and kit synthesis run in the Entry body and must land in the // per-entry fallback rather than escaping to the tree above. - const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => ( - spec.scope === 'session' - ? + const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => { + const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext') + const hookContext = opts?.hookContext + return spec.scope === 'session' + ? ( + + ) : ( {spec.scope === 'session-maybe' - ? - : } + ? ( + + ) + : ( + + )} ) - ) + } if (spec.kind === 'single') { const entry = entries[0] if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'keyed') { const entry = entries.find(e => e.options.key === opts?.entryKey) if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'chain') { // Entries arrive priority-sorted from the ledger (the core orders at @@ -561,7 +773,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { let list = [...withListOptions].sort((a, b) => a.order - b.order) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only) if (list.length === 0) return <>{opts?.fallback ?? null} - return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))} + return <>{list.map(item => guarded(item.entry, entryKeyOf(item.entry)))} } /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */ @@ -575,8 +787,15 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( - - + + ) }