mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring
# Conflicts: # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/subsystems/tools.i18n.yaml # docs/subsystems/tools.md # docs/subsystems/tools.zh.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
265
packages/client/runtime/src/client/contract/conversation.ts
Normal file
265
packages/client/runtime/src/client/contract/conversation.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
|
||||
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
|
||||
* installed business packages supply their concrete keys in consuming Client programs. */
|
||||
|
||||
/** 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<DataMap extends object> {
|
||||
/**
|
||||
* 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 extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | 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<ConversationStepDataMap>
|
||||
}
|
||||
|
||||
/** 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<ConversationTurnDataMap>
|
||||
}
|
||||
|
||||
/** 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<State = unknown> {
|
||||
readonly key: string
|
||||
readonly kind: string
|
||||
readonly id: string
|
||||
readonly matches: readonly ConversationMatch[]
|
||||
readonly start: ConversationMatch | undefined
|
||||
readonly state: State | undefined
|
||||
readonly current: ReadonlyMap<string, ConversationViewNode | null>
|
||||
}
|
||||
|
||||
/** Read-only predecessor returned to a Definition's start function. */
|
||||
export interface ConversationPreviousContext<State = unknown> {
|
||||
readonly key: string
|
||||
readonly kind: string
|
||||
readonly id: string
|
||||
readonly startSeq: number
|
||||
readonly state: Readonly<State>
|
||||
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<State>(kind: string): ConversationPreviousContext<State> | 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<State = unknown> {
|
||||
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<State>,
|
||||
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<State> & { 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<State>,
|
||||
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<State>,
|
||||
target: string,
|
||||
): ConversationViewNode | null
|
||||
}
|
||||
|
||||
/** Reference-stable Turn/Step facts published beside view Nodes. */
|
||||
export interface ConversationTimelineSnapshot {
|
||||
readonly turnOrder: readonly number[]
|
||||
readonly turns: ReadonlyMap<number, TurnLocation>
|
||||
}
|
||||
|
||||
/** Per-Session incremental builder for one view target. */
|
||||
export interface ConversationViewBuilder<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
|
||||
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<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
|
||||
readonly target: string
|
||||
/** @returns a new Session-owned incremental builder. */
|
||||
create(): ConversationViewBuilder<Node, Snapshot>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Service } from 'cordis'
|
||||
|
||||
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
|
||||
export abstract class ConversationDefinitionRegistry<Definition> extends Service {
|
||||
protected readonly definitions = new Map<string, Definition>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationNodeDefinition> {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ConversationViewDefinition> {
|
||||
|
||||
/** @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)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
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<string, ConversationViewNode | null>
|
||||
readonly locationData: Record<ConversationLocationDataScope, ConversationLocationData | null>
|
||||
dependencies: Map<string, Dependency>
|
||||
}
|
||||
|
||||
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<ConversationPublication, number> = {
|
||||
none: 0,
|
||||
'animation-frame': 1,
|
||||
immediate: 2,
|
||||
}
|
||||
|
||||
const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn']
|
||||
|
||||
function emptyLocationData(): Record<ConversationLocationDataScope, ConversationLocationData | null> {
|
||||
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<State>(context: InternalContext): ConversationNodeContext<State> {
|
||||
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<string, InternalContext>()
|
||||
private readonly contextsByKind = new Map<string, InternalContext[]>()
|
||||
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
|
||||
private readonly inputs = new Map<number, ConversationEventInput>()
|
||||
private readonly locationIndex = new ConversationLocationIndex()
|
||||
private readonly dirty = new Set<InternalContext>()
|
||||
private readonly revised = new Set<InternalContext>()
|
||||
private readonly dependents = new Map<string, Set<InternalContext>>()
|
||||
private readonly views = new Map<string, ViewState>()
|
||||
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<string, PendingMatch[]>()
|
||||
for (const entry of fresh) {
|
||||
publication = maximumPublication(publication, this.collectInput(entry, pending))
|
||||
}
|
||||
this.applyPendingMatches(pending, affected)
|
||||
this.replayContexts(affected)
|
||||
if ((this.revised.size > 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<string, ConversationViewNode[]>()
|
||||
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<string, ConversationViewNode[]>()
|
||||
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<string, PendingMatch[]>,
|
||||
): 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<InternalContext>()
|
||||
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<string, readonly PendingMatch[]>,
|
||||
affected: Set<InternalContext>,
|
||||
): void {
|
||||
const startsByKind = new Map<string, InternalContext[]>()
|
||||
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<InternalContext>()
|
||||
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<InternalContext>): 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<string, Dependency>()
|
||||
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<string, Dependency>): 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 affected = new Set<InternalContext>()
|
||||
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 (affected.has(dependent)) continue
|
||||
affected.add(dependent)
|
||||
pending.push(dependent)
|
||||
}
|
||||
}
|
||||
this.replayContexts(affected)
|
||||
return affected.size > 0
|
||||
}
|
||||
|
||||
private readerFor(
|
||||
beforeSeq: number,
|
||||
dependencies: Map<string, Dependency>,
|
||||
): ConversationContextReader {
|
||||
return {
|
||||
previous: <State>(kind: string): ConversationPreviousContext<State> | 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<State>,
|
||||
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<number>): Set<InternalContext> {
|
||||
const affected = new Set<InternalContext>()
|
||||
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 {
|
||||
if (context.definition.buildLocationData === undefined) return null
|
||||
const data = context.definition.buildLocationData(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 })
|
||||
}
|
||||
// Turn publishers may read Step data from this same flush, so each phase
|
||||
// installs the cumulative replacement before the next phase builds.
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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<string, OwnedLocationData>()
|
||||
|
||||
get(key: string): 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<string, OwnedLocationData>): 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<number, StepDraft>
|
||||
}
|
||||
|
||||
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<T>(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<number, Coordinates>()
|
||||
private locations = new Map<number, ConversationLocation>()
|
||||
private seqsByTurn = new Map<number, Set<number>>()
|
||||
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
|
||||
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
|
||||
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
|
||||
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.
|
||||
* @param entries - complete current set of Definition-owned Location values.
|
||||
* @returns whether any published Location data changed.
|
||||
*/
|
||||
replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean {
|
||||
const turns = new Map<number, Map<string, OwnedLocationData>>()
|
||||
const steps = new Map<string, Map<string, OwnedLocationData>>()
|
||||
for (const { owner, data } of entries) {
|
||||
const values = data.kind === 'turn'
|
||||
? turns.get(data.turn) ?? new Map<string, OwnedLocationData>()
|
||||
: steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map<string, OwnedLocationData>()
|
||||
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.
|
||||
* @param changes - incremental removals and replacements from published Contexts.
|
||||
* @returns whether any published Location data changed.
|
||||
*/
|
||||
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<number> {
|
||||
const previousLocations = this.locations
|
||||
const turns = new Map<number, TurnDraft>()
|
||||
const coordinates = new Map<number, Coordinates>()
|
||||
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<number, TurnLocation>()
|
||||
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<number>()
|
||||
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<number> {
|
||||
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<number>()
|
||||
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<number>()
|
||||
current.add(seq)
|
||||
this.seqsByTurn.set(turn, current)
|
||||
}
|
||||
|
||||
private turnData(turn: number): ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
return this.mutableTurnData(turn) as ConversationLocationDataStore<ConversationTurnDataMap>
|
||||
}
|
||||
|
||||
private stepData(turn: number, step: number): ConversationLocationDataStore<ConversationStepDataMap> {
|
||||
return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore<ConversationStepDataMap>
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
|
||||
// Immutability contract: every change swaps the top-level object; unchanged
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
// Publication contract: every change swaps the top-level object; unchanged
|
||||
// substructures keep their references (the React.memo premise). Chat node and
|
||||
// Location stores are stable live readers, so old snapshots are not time-point
|
||||
// views. callId/approvalId stay plain string here (narrow to real brands when
|
||||
// convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -13,6 +15,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 +211,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 +227,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 +316,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 +338,76 @@ export interface PromptError {
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable live per-key reader. An old ChatSnapshot observes later flushes
|
||||
* through this store.
|
||||
*/
|
||||
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 live Location index. An old ChatSnapshot observes later membership
|
||||
* changes through this index.
|
||||
*/
|
||||
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[]
|
||||
}
|
||||
|
||||
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
|
||||
export interface LegacyConversationSlice {
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
readonly turnEnds: ReadonlyMap<number, number>
|
||||
readonly partial: PartialAssistant | null
|
||||
readonly runningCalls: readonly RunningToolCall[]
|
||||
}
|
||||
|
||||
/** Incremental Chat publication with immutable order and stable live keyed readers. */
|
||||
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 top-level compatibility field mirrored from the registered Chat Definitions. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
|
||||
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '' }
|
||||
|
||||
74
packages/client/runtime/src/client/sessions/queue-mirror.ts
Normal file
74
packages/client/runtime/src/client/sessions/queue-mirror.ts
Normal file
@@ -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<MuxFrame, { type: 'session/queue' }>['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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-compact/types'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
@@ -109,48 +112,6 @@ export function inspectRequests(
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryEvent {
|
||||
type: 'llm/retry'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: { message: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number | null }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
type: 'compact/summary'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
summary: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
usage?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number | null; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
@@ -205,10 +166,8 @@ function deriveCallSchemas(
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
const type = event.type as string
|
||||
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as { subCallId: string; name: string }
|
||||
capture(data.subCallId, data.name)
|
||||
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
|
||||
capture(String(event.data.subCallId), event.data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
@@ -351,14 +310,14 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
if (sourceEvent.type === 'llm/retry') {
|
||||
const data = sourceEvent.data
|
||||
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage(event.data.failure),
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
error: displayFailureMessage(data.failure),
|
||||
retry: data.retry,
|
||||
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
|
||||
retryDelayMs: data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -374,8 +333,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'session/end-seed' && activeCompaction !== undefined) {
|
||||
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
@@ -384,37 +342,36 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
activeCompaction = undefined
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
if (sourceEvent.type === 'compact/start') {
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
startSeq: sourceEvent.seq,
|
||||
turn: sourceEvent.data.turn,
|
||||
step: 0,
|
||||
startedAt: event.time,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const data = sourceEvent.data
|
||||
updateCompaction(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
resultSeq: sourceEvent.seq,
|
||||
summary: data.summary,
|
||||
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
|
||||
provenance: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
purpose: 'compaction',
|
||||
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
|
||||
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
|
||||
},
|
||||
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
|
||||
...(data.usage === undefined ? {} : { usage: data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -426,12 +383,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
completedAt: sourceEvent.time,
|
||||
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
|
||||
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -265,16 +266,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<SessionSelection>(
|
||||
{},
|
||||
{ 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<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
@@ -302,6 +317,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, RunningToolCall>()
|
||||
/** Last entered step per turn, folded from step/start for terminal error placement. */
|
||||
private lastStepByTurn = new Map<number, number>()
|
||||
/** 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<string, PendingInteraction>()
|
||||
// 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<number, { startTime: number; endTime?: number }>()
|
||||
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<number, number>()
|
||||
private turnEndsRev = 0
|
||||
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | 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]
|
||||
@@ -382,6 +344,7 @@ export class Session implements SessionFace {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
|
||||
this.hasMore = false
|
||||
this.conversation.prepend([], false)
|
||||
return
|
||||
}
|
||||
this.events = [...older.map(e => e.event), ...this.events]
|
||||
@@ -389,8 +352,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 +423,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 +433,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 +477,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 +541,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 +607,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 +617,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 +645,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 +675,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<ModelRetryNode['retryState'], 'scheduled'>,
|
||||
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 +729,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<string, unknown>
|
||||
const failure = data.failure
|
||||
if (failure === null || typeof failure !== 'object') return null
|
||||
const failureData = failure as Record<string, unknown>
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
type InboxTarget = 'next-turn' | 'next-step'
|
||||
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
|
||||
/** Minimal pending identity retained while replaying durable inbox splices. */
|
||||
interface PendingIdentity {
|
||||
@@ -45,8 +44,8 @@ export class SteeringHistory {
|
||||
* @returns true only for a user-origin message previously claimed from `next-step`.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'agent/inbox/spliced') {
|
||||
this.applySplice(event.data as unknown as InboxSplice)
|
||||
if (event.type === 'agent/inbox/spliced') {
|
||||
this.applySplice(event.data)
|
||||
return false
|
||||
}
|
||||
if (event.type !== 'user/message') return false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type {
|
||||
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from './conversation.ts'
|
||||
@@ -55,13 +55,8 @@ export class ToolCallTree {
|
||||
* @returns Whether the event was consumed as a child-call lifecycle event.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
if (event.type === 'tool/code-dispatch-start') {
|
||||
const data = event.data
|
||||
const running: RunningToolCall = {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
@@ -78,15 +73,8 @@ export class ToolCallTree {
|
||||
this.revision++
|
||||
return true
|
||||
}
|
||||
if ((event.type as string) !== 'tool/code-dispatch') return false
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
if (event.type !== 'tool/code-dispatch') return false
|
||||
const data = event.data
|
||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
|
||||
|
||||
@@ -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<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
steering: boolean,
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): 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<number, SessionEvent>,
|
||||
): 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<number, SessionEvent>()
|
||||
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
|
||||
private projected: ConversationNode[] = []
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
|
||||
private stepTimings = new Map<string, AssistantStepMetadata>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/** 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<string, CommandNode>()
|
||||
/** 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<number>()
|
||||
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).
|
||||
}
|
||||
}
|
||||
@@ -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<never>).scope
|
||||
const scope = (this._core.specDynamic(options.name) as SlotSpec<SlotEntryDef>).scope
|
||||
this._acquire(store, scope)
|
||||
}
|
||||
let disposed = false
|
||||
|
||||
Reference in New Issue
Block a user