diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index afa2ff3604..3b3e45ab32 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 } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts' -import { deriveEventMessage, SurfaceManager } from './surface.ts' +import { deriveEventMessage, isSurfaceEvent, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' @@ -424,6 +424,9 @@ export class Session { private log: SessionEvent[] = [] /** Single incremental owner of surface acceptance and projection state. */ private readonly surfaceManager = new SurfaceManager(this.log) + /** 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 { @@ -639,6 +642,7 @@ export class Session { callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]) } this.log.push(event as SessionEvent) + this.extendSelectedMessageSurface(event as SessionEvent) this.eventsSnapshot = undefined if (callbacks !== undefined && entry !== undefined) { invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks) @@ -652,6 +656,74 @@ export class Session { } } + /** + * 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 + if (event.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 + } + const start = selected.indexOf(event.surfaceOp.start) + const end = selected.indexOf(event.surfaceOp.end) + 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 resultBlock = event.data.message.content.find(block => block.type === 'tool-result') + if (resultBlock?.type !== 'tool-result') return false + 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 === resultBlock.toolCallId) + }) + } + /** Cached fold of the request-header events — see {@link requestHeader}. */ private headerFold: EpochHeader | undefined /** Log position (events consumed) the header fold has reached. */ @@ -702,6 +774,7 @@ export class Session { private derivedNodes = 0 /** {@link SurfaceManager.replaceGeneration} the cache was built under. */ private derivedGeneration = 0 + private derivedSelectionGeneration = 0 /** * Derive the LLM message history by walking the ordered sequences of @@ -723,12 +796,14 @@ export class Session { */ deriveMessages(): Message[] { const surface = this.surface - const nodes = surface.nodes + const nodes = this.selectedMessageSurface ?? surface.nodes const generation = surface.replaceGeneration - 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 774e1a55cb..52cf993b12 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -47,6 +47,11 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', + 'session-tree/branch', + 'session-tree/cursor', + 'session-tree/node', + 'session-tree/selection', + 'session-tree/snapshot', 'session/end-seed', 'session/title', 'session/title-llm-request',