diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e1ecb920..0efcca94 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,7 +16,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, PluginRecord, PluginRecordMap, PluginRecordType, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' -import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts' +import { isSurfaceEvent, SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts' import type { SessionSurface, SessionMessageProjection } from './surface.ts' import { foldRequestHeader } from './request-header.ts' import { ToolHistoryProjection } from './tool-history.ts' @@ -524,6 +524,10 @@ export class Session { /** Single incremental owner of surface acceptance and projection state. */ private readonly surfaceManager: SurfaceManager + /** Optional branch-selected message surface; undefined follows the canonical surface. */ + private selectedMessageSurface: number[] | undefined + private selectedMessageSurfaceGeneration = 0 + /** The ordered surface over this session's event log. */ get surface(): SessionSurface { return this.surfaceManager @@ -884,6 +888,7 @@ export class Session { callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]) } this.log.push(event) + this.extendSelectedMessageSurface(event) this.eventsSnapshot = undefined if (callbacks !== undefined && entry !== undefined) { invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks) @@ -956,12 +961,83 @@ export class Session { return this.toolHistoryProjection.snapshot() } + /** + * Select an existing message path as the model-visible branch. + * + * The append-only event log and canonical surface remain untouched. Future + * append-origin messages extend this selection, while positional replacements + * rewrite only a selected range they actually cover. Passing `null` returns + * to the canonical surface. This is the Session-level leaf pointer used by + * in-session tree navigators. + * @param nodes - ordered surface-event seqs, an empty root path, or null. + */ + selectMessageSurface(nodes: readonly number[] | null): void { + const next = nodes === null ? undefined : [...nodes] + if (next !== undefined) { + const seen = new Set() + for (const seq of next) { + const event = this.log[seq] + if (!Number.isSafeInteger(seq) || seq < 0 || event?.seq !== seq || !isSurfaceEvent(event) || seen.has(seq)) { + throw new Error(`invalid selected message-surface event seq ${seq}`) + } + seen.add(seq) + } + } + const previous = this.selectedMessageSurface + if (previous === undefined && next === undefined) return + if (previous !== undefined && next !== undefined + && previous.length === next.length && previous.every((seq, index) => seq === next[index])) return + this.selectedMessageSurface = next + this.selectedMessageSurfaceGeneration += 1 + } + + /** Current model-visible surface nodes, including an active branch selection. */ + messageSurfaceNodes(): readonly number[] { + return [...(this.selectedMessageSurface ?? this.surface.nodes)] + } + + /** Keep a selected leaf path live as new surface events are appended. */ + private extendSelectedMessageSurface(event: SessionEvent): void { + const selected = this.selectedMessageSurface + if (selected === undefined || !isSurfaceEvent(event)) return + const surfaceOp = event.surfaceOp + if (surfaceOp === 'append') { + // A navigation command itself runs as a tool call. Its result is + // appended after the cursor has moved, but its assistant tool-call + // message belongs to the abandoned leaf. Do not put that orphan result + // into the newly selected provider history. Future tool results are + // admitted once their matching assistant tool-call is selected. + if (event.type === 'tool/result' && !this.selectedSurfaceContainsToolCall(event)) return + selected.push(event.seq) + this.selectedMessageSurfaceGeneration += 1 + return + } + if (typeof surfaceOp !== 'object' || surfaceOp.op !== 'replace') return + const start = selected.indexOf(surfaceOp.startSeq) + const end = selected.indexOf(surfaceOp.endSeq) + if (start < 0 || end < start) return + selected.splice(start, end - start + 1, event.seq) + this.selectedMessageSurfaceGeneration += 1 + } + + /** Whether a tool result's assistant tool-call is already on the selected path. */ + private selectedSurfaceContainsToolCall(event: Extract): boolean { + const toolCallId = event.data.message.toolCallId + return (this.selectedMessageSurface ?? []).some(seq => { + const selected = this.log[seq] + if (selected?.type !== 'assistant/message') return false + return selected.data.message.content.some(block => block.type === 'tool-call' && block.id === toolCallId) + }) + } + /** The derived-message cache: frozen projections, extended per unseen node. */ private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ private derivedNodes = 0 /** {@link SurfaceManager.contentGeneration} the cache was built under. */ private derivedGeneration = 0 + /** Selected-message-surface generation the cache was built under. */ + private derivedSelectionGeneration = 0 /** * Derive the LLM message history by walking the ordered sequences of @@ -983,12 +1059,14 @@ export class Session { */ deriveMessages(): Message[] { const surface = this.surface - const nodes = surface.nodes + const nodes = this.selectedMessageSurface ?? surface.nodes const generation = surface.contentGeneration - if (generation !== this.derivedGeneration) { + const selectionGeneration = this.selectedMessageSurfaceGeneration + if (generation !== this.derivedGeneration || selectionGeneration !== this.derivedSelectionGeneration) { this.derived = [] this.derivedNodes = 0 this.derivedGeneration = generation + this.derivedSelectionGeneration = selectionGeneration } for (const seq of nodes.slice(this.derivedNodes)) { // Surface sequences are built from this.log — seq is always a valid diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index a0f5c177..28457e5b 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -20,6 +20,11 @@ * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`. */ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ + 'session-tree/branch', + 'session-tree/cursor', + 'session-tree/node', + 'session-tree/selection', + 'session-tree/snapshot', 'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked',