Merge remote-tracking branch 'origin/master' into xtr/trajectory-timeline
# Conflicts: # apps/web/tests/code-mode-fixture.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # apps/web/tests/snapshots/navigation-panes/details-open.expected.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/fold-adapter.ts # packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
@@ -7,6 +7,7 @@ import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
@@ -29,7 +30,7 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
@@ -43,6 +44,11 @@ export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
@@ -68,12 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
/** Key-addressed projection reader; every key reads absent while no session is current. */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -152,6 +153,31 @@ export interface UnknownSurfaceNode {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* One slash-command lifecycle folded from the log-only `command/run` /
|
||||
* `command/done` pair (paired by commandId, mirroring tool call↔result).
|
||||
* Log-only events never enter the surface fold, so the FoldAdapter indexes
|
||||
* them separately and merges the nodes into the flow 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.
|
||||
*/
|
||||
export interface CommandNode {
|
||||
kind: 'command'
|
||||
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the anchoring event. */
|
||||
time: number
|
||||
/** Pairing id minted by the host executor. */
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
@@ -159,6 +185,7 @@ export type ConversationNode =
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
@@ -277,7 +304,4 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
|
||||
* write (last write wins); empty = the log holds no plan. */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, ConversationNode,
|
||||
AssistantRequestConfig, AssistantTiming, CommandNode, ConversationNode,
|
||||
} from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import type {
|
||||
@@ -182,6 +183,15 @@ export class FoldAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so the surface fold never
|
||||
* emits it; this index folds the pair (done settles its run's node in
|
||||
* place) and nodes() merges the products into the flow 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>()
|
||||
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
|
||||
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
@@ -228,12 +238,14 @@ export class FoldAdapter {
|
||||
this.contextGeneration = 0
|
||||
this.activePrompt = undefined
|
||||
this.promptsByContext = new Map()
|
||||
this.commandIdx = 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) {
|
||||
this.indexCall(event, views?.[i])
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +264,7 @@ export class FoldAdapter {
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +292,23 @@ export class FoldAdapter {
|
||||
/* v8 ignore next -- both seq sources only emit indexes present in padded. */
|
||||
if (node !== undefined) out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
// order share the log order), so one linear merge keeps flow order.
|
||||
let nodes = out
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of out) {
|
||||
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)
|
||||
}
|
||||
const value = { nodes, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -417,6 +446,36 @@ export class FoldAdapter {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// 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, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
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
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
|
||||
@@ -9,7 +9,12 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
// Type-only merge edge: the title domain's client-namespace outlet declares
|
||||
// the 'title' projection key this manager projects into list rows (and any
|
||||
// useProjection('title') consumer reads). Zero value imports by construction.
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import { Session } from './session.ts'
|
||||
|
||||
/**
|
||||
@@ -43,12 +48,6 @@ type SessionListMutation =
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Latest title control snapshot retained independently of list/instance arrival. */
|
||||
interface SessionTitleSnapshot {
|
||||
title: string
|
||||
eventSeq: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
@@ -58,7 +57,11 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
* same store so history-baseline seeding and frames converge on one row set. */
|
||||
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
||||
@@ -163,9 +166,23 @@ export class SessionManager {
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
||||
private projectionStore(sessionId: SessionId): ProjectionValueStore {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -302,23 +319,20 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (frame.type === 'session/title') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
|
||||
this.titleSnapshots.set(frame.sessionId, {
|
||||
title: frame.title,
|
||||
eventSeq: frame.eventSeq,
|
||||
updatedAt: frame.updatedAt,
|
||||
})
|
||||
if (frame.type === 'session/projection') {
|
||||
// Finished host-computed value: land it in the resident store whether or
|
||||
// not the Session is instantiated (list rows read the 'title' key). The
|
||||
// synchronous markDirty keeps the list snapshot same-tick fresh (the
|
||||
// store's own any-key channel is microtask-batched).
|
||||
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq > frame.lastSeq) {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: buffered session/queued frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
@@ -377,7 +391,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
@@ -402,10 +416,12 @@ export class SessionManager {
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
const title = this.titleSnapshots.get(summary.sessionId)
|
||||
return title === undefined
|
||||
? summary
|
||||
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const items = fresh.map((entry) => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generic per-session projection value store (session-projection RFC, push
|
||||
* model): the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by the history tail
|
||||
* page's projections block and updated by `session/projection` push frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
* exists: a domain ships projection support with zero client code. Per-key
|
||||
* bare observable faces feed `useProjection` (web-react binds them).
|
||||
*/
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the interface package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (user ruling, RFC Alternatives).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (session-projection RFC): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
* binding; reference stability holds because a key's value reference changes
|
||||
* only when a frame or baseline lands).
|
||||
*/
|
||||
export type UseProjection = {
|
||||
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends Extract<keyof SessionProjectionMap, string>, S>(
|
||||
key: K,
|
||||
selector: (value: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean,
|
||||
): S
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to the wire's
|
||||
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
|
||||
* React-free store depends only on the type table, not the wire package's
|
||||
* response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** One key's row: the latest finished value and the seq it is consistent with. */
|
||||
interface Row {
|
||||
value: unknown
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Per-key notification channel: the bare face plus its batching notifier. */
|
||||
interface Channel {
|
||||
face: ObservableSnapshot<unknown>
|
||||
notifier: Notifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's projection values. Framework semantics, uniform across every
|
||||
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
|
||||
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
|
||||
* value, a stale baseline cannot overwrite a newer frame. A key the store has
|
||||
* never seen reads `undefined` (capability absent). Faces are identity-stable
|
||||
* per key (create-on-demand, cached) so the React side binds each exactly
|
||||
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
|
||||
* manager's list projection reads the `title` key).
|
||||
*/
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
/**
|
||||
* Key-addressed bare observable face (the useProjection resolution path).
|
||||
* Always defined — absence is an `undefined` snapshot, never a missing
|
||||
* face, so a component may subscribe before the key ever carries a value.
|
||||
* @param key - projection key.
|
||||
* @returns the identity-stable face for this key.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown> {
|
||||
return this.channel(key).face
|
||||
}
|
||||
|
||||
/**
|
||||
* Current whole value for a key (erased framework read; typed reads go
|
||||
* through `useProjection`'s map lookup).
|
||||
* @param key - projection key.
|
||||
* @returns the value, or undefined while the key is absent.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribeAny(listener: () => void): () => void {
|
||||
return this.anyNotifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one finished value (the `session/projection` push-frame path).
|
||||
* @param key - projection key.
|
||||
* @param value - whole value computed by the host unit.
|
||||
* @param seq - the unit's watermark at emission.
|
||||
*/
|
||||
apply(key: string, value: unknown, seq: number): void {
|
||||
const row = this.rows.get(key)
|
||||
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
|
||||
this.rows.set(key, { value, seq })
|
||||
this.changed(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed from a history tail page's projections block: every carried key
|
||||
* lands under the same seq rule as frames; a key the block omits is
|
||||
* capability-absent as of the cut — its row clears unless a newer frame
|
||||
* already superseded the cut (a stale baseline can neither overwrite nor
|
||||
* clear newer values).
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
seed(baseline: ProjectionsBaseline): void {
|
||||
// Erased walk: the framework crosses the open key space; per-key typing
|
||||
// is re-established at the consumer (useProjection's map lookup).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
|
||||
for (const [key, row] of this.rows) {
|
||||
if (Object.hasOwn(values, key)) continue
|
||||
if (row.seq > baseline.asOfSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
|
||||
* a row claiming knowledge beyond the host's own durable baseline rode
|
||||
* state a restart lost — under last-wins it would wrongly outrank the
|
||||
* host's recomputed (lower-seq) values forever. Durable replay and the next
|
||||
* baseline re-seed whatever truly survived (the title-snapshot precedent,
|
||||
* generalized).
|
||||
* @param lastSeq - the subscribed frame's durable baseline seq.
|
||||
*/
|
||||
truncate(lastSeq: number): void {
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.seq <= lastSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
private channel(key: string): Channel {
|
||||
let channel = this.channels.get(key)
|
||||
if (channel === undefined) {
|
||||
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
|
||||
const notifier = new Notifier(() => {})
|
||||
channel = {
|
||||
notifier,
|
||||
face: {
|
||||
getSnapshot: () => this.rows.get(key)?.value,
|
||||
subscribe: listener => notifier.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.channels.set(key, channel)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ export class SessionsService {
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
@@ -334,7 +334,14 @@ export class SessionsService {
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
hooks,
|
||||
props,
|
||||
// The useProjection seat: key-addressed bare value faces off the
|
||||
// session's projection store (open key space — never a static roster member).
|
||||
projections: { faceOf: key => binding.session.projections.faceOf(key) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
@@ -23,6 +23,8 @@ import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -38,6 +40,12 @@ export interface SessionOptions {
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
/**
|
||||
* Manager-owned projection value store to adopt (frames route through the
|
||||
* manager and values outlive instantiation); omitted, the Session owns a
|
||||
* 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. */
|
||||
@@ -103,9 +111,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
|
||||
* field is the authoritative empty list) and every live write overwrites it. */
|
||||
private todos: readonly TodoItem[] = []
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -137,6 +142,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
/**
|
||||
* Per-session projection value store (session-projection RFC, push model):
|
||||
* finished whole values computed on the host, seeded by the tail page's
|
||||
* projections block and updated by `session/projection` frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
* carries projection values, and no client-side domain folding exists.
|
||||
* Manager-owned when constructed through SessionManager (frames route and
|
||||
* the store outlives instantiation, the title-snapshot precedent); a bare
|
||||
* construction gets a private store.
|
||||
*/
|
||||
readonly projections: ProjectionValueStore
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -160,6 +178,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -523,13 +542,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -546,23 +565,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.historyRev++
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
// Session-level projection from the tail page (full-log latest todo/write,
|
||||
// independent of the window); an in-window write below re-derives the same
|
||||
// value, and later live events keep overwriting it. Every caller here is a
|
||||
// tail request (no beforeSeq), which the host answers with the projection
|
||||
// or omits it only when the full log holds no todo/write — so an absent
|
||||
// field is the authoritative empty list, not a missing carrier. Assigning
|
||||
// it clears a plan the log never kept (a write lost to a host crash).
|
||||
this.todos = todos ?? []
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -612,7 +627,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -732,10 +747,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
this.todos = event.data.todos
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
// 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.
|
||||
@@ -780,10 +791,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
|
||||
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
|
||||
* projection, not derivable from an arbitrary window). The window always extends to the log
|
||||
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
@@ -854,7 +862,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
todos: this.todos,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,17 @@ export class WorkspacesService {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
const response = await this.api.host.openPath({ path })
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`path open failed: ${response.result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
Reference in New Issue
Block a user