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:
Yichen Jiang
2026-08-09 21:38:26 +08:00
266 changed files with 12118 additions and 4857 deletions

View 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}`
}

View File

@@ -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()
}
}

View File

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

View File

@@ -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)})`,
)
}
}

View File

@@ -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),
})

View File

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

View File

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

View File

@@ -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`)
}

View File

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

View File

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

View File

@@ -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: '' }

View 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
}
}

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

@@ -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).
}
}

View File

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