Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -23,30 +23,30 @@ export * from './types.ts'
|
||||
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed message-triggered turn, ignoring other triggers and
|
||||
* between-turn events.
|
||||
* Find the latest closed turn that entered at least one model step, ignoring
|
||||
* balanced no-step turns produced by rejection, empty input, or cancellation.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const messageTurns = new Set<number>()
|
||||
const steppedTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
|
||||
if (event.type === 'step/start') {
|
||||
steppedTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
|
||||
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
@@ -93,8 +93,7 @@ declare module 'cordis' {
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited parallel durability checkpoint: every listener runs and the
|
||||
* caller awaits all of them, with no waterfall veto. Dispatch through
|
||||
* {@link SessionStore.flush}. Scope-filtered dispatch
|
||||
* caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
|
||||
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @dshScopeScan unsupported
|
||||
@@ -104,17 +103,12 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
/** Validate and freeze one detached creation header in place. */
|
||||
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
const record = snapshot as Record<string, unknown>
|
||||
const record = input as Record<string, unknown>
|
||||
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
|
||||
}
|
||||
@@ -139,6 +133,9 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
if (record.origin !== undefined && record.origin !== 'subagent') {
|
||||
throw new Error('session header origin must be "subagent"')
|
||||
}
|
||||
if (record.delegationDepth !== undefined
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
@@ -146,31 +143,51 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
return validateSessionHeader(id, snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an exclusively owned event and deeply freeze its identified message
|
||||
* without copying the event. The caller transfers an object graph that no
|
||||
* producer retains and that shares no mutable children with another event.
|
||||
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
|
||||
* @param event - exclusively owned event imported across a trusted boundary.
|
||||
* @returns the same event object with a validated, deeply frozen message.
|
||||
*/
|
||||
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
assertMessageEventShape(
|
||||
event,
|
||||
`session event at seq ${event.seq}`,
|
||||
)
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
deepFreeze(event.data)
|
||||
break
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
deepFreeze(event.data.message)
|
||||
break
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
|
||||
break
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one event while preserving deep immutability for its identified message.
|
||||
* @param event - event imported across a query or persistence boundary.
|
||||
* @returns a detached event snapshot with a validated, deeply frozen message.
|
||||
*/
|
||||
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
const snapshot = structuredClone(event)
|
||||
assertMessageEventShape(
|
||||
snapshot,
|
||||
`session event at seq ${snapshot.seq}`,
|
||||
)
|
||||
switch (snapshot.type) {
|
||||
case 'user/message':
|
||||
deepFreeze(snapshot.data)
|
||||
break
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'steering/message':
|
||||
deepFreeze(snapshot.data.message)
|
||||
break
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
|
||||
break
|
||||
}
|
||||
return snapshot
|
||||
return adoptSessionEvent(structuredClone(event))
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
@@ -190,7 +207,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
assertCurrentLlmShape(event, index)
|
||||
assertCurrentTurnEndShape(event, index)
|
||||
}
|
||||
|
||||
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
|
||||
@@ -201,25 +217,50 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
: undefined
|
||||
if (event['type'] === 'request/header') {
|
||||
const header = record?.['header']
|
||||
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
|
||||
const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
|
||||
? header as Record<string, unknown>
|
||||
: undefined
|
||||
const config = headerRecord?.['config']
|
||||
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
|
||||
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
|
||||
const configRecord = config as Record<string, unknown>
|
||||
const reasoningEffort = configRecord['reasoningEffort']
|
||||
if (reasoningEffort !== undefined
|
||||
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
|
||||
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
|
||||
}
|
||||
assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index)
|
||||
}
|
||||
const type = event['type']
|
||||
if (type !== 'user/message' && type !== 'assistant/message'
|
||||
&& type !== 'tool/result' && type !== 'steering/message') return
|
||||
&& type !== 'tool/result') return
|
||||
assertMessageEventShape(event, `seed ${type} at index ${index}`)
|
||||
}
|
||||
|
||||
/** Validate adapter-default provenance imported from a durable request header. */
|
||||
function assertAdapterDefaults(
|
||||
value: unknown,
|
||||
config: Record<string, unknown>,
|
||||
index: number,
|
||||
): void {
|
||||
if (value === undefined) return
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
|
||||
}
|
||||
const defaults = value as Record<string, unknown>
|
||||
const allowed = new Set(['reasoningEffort', 'maxTokens'])
|
||||
if (Object.keys(defaults).some(key => !allowed.has(key))
|
||||
|| Object.values(defaults).some(marker => marker !== true)
|
||||
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|
||||
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
|
||||
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate only the event-specific invariants needed to safely replay a message. */
|
||||
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
|
||||
const type = event['type']
|
||||
if (type !== 'user/message' && type !== 'assistant/message'
|
||||
&& type !== 'tool/result' && type !== 'steering/message') return
|
||||
&& type !== 'tool/result') return
|
||||
const data = event['data']
|
||||
const record = typeof data === 'object' && data !== null
|
||||
? data as Record<string, unknown>
|
||||
@@ -269,22 +310,6 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
|
||||
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
|
||||
if (event['type'] !== 'turn/end') return
|
||||
const data = event['data']
|
||||
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
const reason = (data as Record<string, unknown>)['reason']
|
||||
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
|
||||
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
|
||||
const record = reason as Record<string, unknown>
|
||||
if (record['kind'] === 'aborted'
|
||||
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
|
||||
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an unknown value carries the current provider/model pair. */
|
||||
function hasProviderModel(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
@@ -351,8 +376,10 @@ const attachments = new WeakMap<Session, SessionEntry>()
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Plain class (not a Service) — create live instances via
|
||||
* `ctx.sessions.create()` and detached instances via {@link create}.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
@@ -367,7 +394,7 @@ export class Session {
|
||||
/**
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
|
||||
* `Session` is created without a store-owned header, a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
* storage concern, not replayable conversation state.
|
||||
@@ -381,19 +408,42 @@ export class Session {
|
||||
|
||||
/**
|
||||
* The first seq appended IN THIS PROCESS: the length of the constructor
|
||||
* seed (0 without one). Events below it entered through construction —
|
||||
* replay, fork, or resume — and were never published on the `session/event`
|
||||
* firehose (constructor seeds do not emit), so consumers that replay the
|
||||
* log as a publication substitute (telemetry adoption) start here. Distinct
|
||||
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
|
||||
* session's constructor seed is its full stored log, while its header keeps
|
||||
* the original fork value — this field is the in-process construction fact
|
||||
* and is deliberately not persisted.
|
||||
* seed (0 without one). Events with smaller seq values entered through
|
||||
* construction — replay, fork, or resume — and were never published on the
|
||||
* `session/event` firehose (constructor seeds do not emit), so consumers
|
||||
* that replay the log as a publication substitute (telemetry adoption)
|
||||
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
|
||||
* boundary: a resumed session's constructor seed is its full stored log,
|
||||
* while its header keeps the original fork value — this field is the
|
||||
* in-process construction fact.
|
||||
*
|
||||
* Not persisted itself: a seeded session projects it into the log as the
|
||||
* `session/end-seed` event, which is what a consumer reading STORED history
|
||||
* reads. Locate the LAST such event, not necessarily one at this seq — a
|
||||
* seed already ending in one is not re-marked, so reopening an untouched
|
||||
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
|
||||
* this field in-process: it is exact before the marker reaches storage.
|
||||
*
|
||||
* When this lifecycle appends the marker, it occupies this seq before the
|
||||
* store attaches and therefore does not publish either. Otherwise this seq
|
||||
* holds an ordinary published write.
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
/**
|
||||
* Create a detached session by validating and snapshotting borrowed seed
|
||||
* events and storage metadata.
|
||||
* @param id - session identity.
|
||||
* @param seed - optional borrowed replay or fork events.
|
||||
* @param header - optional borrowed storage metadata.
|
||||
* @returns a detached session.
|
||||
*/
|
||||
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
|
||||
return new Session(id, seed, header)
|
||||
}
|
||||
|
||||
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed !== undefined) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
// live log that no persistence backend could store: each event's `data`
|
||||
@@ -426,6 +476,13 @@ export class Session {
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
// Appended here so the marker is already in `events` when a backend
|
||||
// captures the creation seed: no load-time write. Re-marking is skipped
|
||||
// because a cold session is resumed on first touch, so repeatedly opening
|
||||
// one must not grow its log per open.
|
||||
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
|
||||
this.append('session/end-seed', {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached immutable public snapshot of the private append-only log. */
|
||||
@@ -461,7 +518,8 @@ export class Session {
|
||||
* the ordered surface; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* declare how it joins the surface, the sole source of derived model
|
||||
* history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
@@ -559,6 +617,25 @@ export class Session {
|
||||
return this.headerFold
|
||||
}
|
||||
|
||||
/** Cached fold of `request/context` events. */
|
||||
private contextFold: RequestContext | undefined
|
||||
private contextFoldSeq = 0
|
||||
|
||||
/**
|
||||
* Return the latest resolved route metadata, or `undefined` before the first
|
||||
* `request/context` event. Each event is folded once.
|
||||
* @returns the latest immutable route metadata.
|
||||
*/
|
||||
requestContext(): RequestContext | undefined {
|
||||
if (this.contextFoldSeq < this.log.length) {
|
||||
for (const event of this.log.slice(this.contextFoldSeq)) {
|
||||
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
|
||||
}
|
||||
this.contextFoldSeq = this.log.length
|
||||
}
|
||||
return this.contextFold
|
||||
}
|
||||
|
||||
/** The derived-message cache: frozen projections, extended per unseen node. */
|
||||
private derived: Message[] = []
|
||||
/** Surface position (nodes projected) the cache has reached. */
|
||||
@@ -596,7 +673,7 @@ export class Session {
|
||||
for (const seq of nodes.slice(this.derivedNodes)) {
|
||||
// Surface sequences are built from this.log — seq is always a valid
|
||||
// index by construction. The non-null assertion expresses that invariant.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const msg = this.deriveEventMessage(this.log[seq]!)
|
||||
// A surface node is one of the five message-producing types, but an
|
||||
// empty-content assistant/message (a max-tokens step that hosts only
|
||||
@@ -626,10 +703,9 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
// Ordinary prompts, injected context, and mid-turn steering project
|
||||
// identically in user role: the event's model-facing content stays
|
||||
// verbatim. Steering's `turn` is log-only. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// Ordinary prompts and injected context project in user role: the
|
||||
// event's model-facing content stays verbatim. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
@@ -638,9 +714,6 @@ export class Session {
|
||||
case 'user/message': {
|
||||
return event.data
|
||||
}
|
||||
case 'steering/message': {
|
||||
return event.data.message
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
@@ -708,7 +781,7 @@ export class SessionStore extends Service {
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* loop's final events are published before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see
|
||||
* `dsh-agent-loop`'s creation transaction).
|
||||
@@ -740,7 +813,7 @@ export class SessionStore extends Service {
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
* before the driver's closing events commit, dropping them.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
@@ -767,9 +840,10 @@ export class SessionStore extends Service {
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.origin === undefined ? {} : { origin: meta.origin },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
return Session.create(sessionId, seed, header)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -893,15 +967,17 @@ export class SessionStore extends Service {
|
||||
/**
|
||||
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
|
||||
* with the carrier captured at {@link enter}. THE flush entry point: the
|
||||
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
|
||||
* injection, teardown drains) must come through here rather than dispatch a
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* store owns the carrier, so callers (the checkpoint policy's per-request
|
||||
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
|
||||
* that flush themselves before reading storage) must come through here
|
||||
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
|
||||
* one spelling, and the scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns resolves when every flush listener has settled; after all settle,
|
||||
* rejects with the first registered listener failure if any listener failed.
|
||||
* @returns whether at least one durability listener participated, after every
|
||||
* listener has settled successfully.
|
||||
* @throws the first registered listener failure after every listener settles.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
async flush(session: Session): Promise<boolean> {
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
|
||||
@@ -911,12 +987,13 @@ export class SessionStore extends Service {
|
||||
} catch (error: unknown) {
|
||||
// Preserve the listener's exact rejection value; flush is a caller-owned
|
||||
// failure boundary, and Cordis listeners may throw arbitrary values.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
return callbacks.length > 0
|
||||
}
|
||||
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
|
||||
@@ -144,9 +144,12 @@ function validateEvent(
|
||||
}
|
||||
case 'user/message':
|
||||
break
|
||||
case 'steering/message':
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'todo/write':
|
||||
case 'request/header': {
|
||||
case 'request/header':
|
||||
case 'request/context': {
|
||||
if (trace.openTurn === null) {
|
||||
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
||||
* written final turn and supplies the missing tool, step, and turn boundaries
|
||||
* needed to resume with a provider-valid transcript.
|
||||
* needed to resume with a provider-valid transcript, plus the activity-time
|
||||
* read that must skip the end-seed boundary — which this module does
|
||||
* not write (`Session`'s constructor does) but whose synthetic closers can
|
||||
* inherit that boundary's timestamp, the one real coupling between the two.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -9,6 +12,22 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* The `time` of the log's last event representing actual work, skipping the
|
||||
* `session/end-seed` boundary — picking a session up is not activity, so
|
||||
* activity ordering must exclude it.
|
||||
*
|
||||
* Excluded by type, so a pickup time still leaks when a boundary is the last
|
||||
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
|
||||
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
|
||||
* unbalanced log directly — `load()` balances first.
|
||||
* @param events - the log to scan, in seq order.
|
||||
* @returns the latest non-boundary event's `time`, or undefined when there is none.
|
||||
*/
|
||||
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
|
||||
return events.findLast(event => event.type !== 'session/end-seed')?.time
|
||||
}
|
||||
|
||||
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
||||
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
|
||||
|
||||
|
||||
@@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts'
|
||||
* @returns the canonical header.
|
||||
*/
|
||||
export function canonicalHeader(header: EpochHeader): EpochHeader {
|
||||
const adapterDefaults = header.adapterDefaults
|
||||
return {
|
||||
config: header.config,
|
||||
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
|
||||
? { adapterDefaults }
|
||||
: {},
|
||||
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
||||
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
||||
}
|
||||
@@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
|
||||
* @returns whether config, system, and tools all match.
|
||||
*/
|
||||
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
||||
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
||||
if (
|
||||
!callConfigEquals(a.config, b.config)
|
||||
|| a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
|
||||
|| a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
|
||||
|| a.system !== b.system
|
||||
) return false
|
||||
const at = a.tools ?? []
|
||||
const bt = b.tools ?? []
|
||||
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
||||
|
||||
@@ -15,13 +15,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
'user/message',
|
||||
'assistant/message',
|
||||
'tool/result',
|
||||
'steering/message',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether an event type can join the model-visible surface.
|
||||
* @param type - event type to test.
|
||||
* @returns true for one of the four message-producing event types.
|
||||
* @returns true for one of the three message-producing event types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
@@ -37,6 +36,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to an append-origin surface event: one that entered the
|
||||
* surface at its own log position and was never itself a replacement copy.
|
||||
*
|
||||
* The model-visible surface deliberately shadows replaced ranges, so it is the
|
||||
* wrong source for a human transcript — a landed replacement would erase
|
||||
* conversation the user already saw. Append-origin events are that transcript's
|
||||
* durable source material; replacement copies stay model-only.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event appended to the surface tail.
|
||||
*/
|
||||
export function isAppendSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: 'append' } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp === 'append'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to a surface replacement: a node that shadowed an existing
|
||||
* surface range instead of appending to the tail. The counterpart of
|
||||
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event replaced a surface range.
|
||||
*/
|
||||
export function isReplacementSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
|
||||
}
|
||||
|
||||
/** One replacement operation observed while folding a session surface. */
|
||||
export interface SurfaceFoldReplacement {
|
||||
/** Seq of the event that replaced the prior surface range. */
|
||||
@@ -212,13 +241,14 @@ function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
baseSeq: number,
|
||||
): void {
|
||||
if (event.type !== 'tool/result') return
|
||||
if (shadowedSeqs.length !== 1) {
|
||||
throw new Error('tool/result surface replacement must rewrite exactly one current node')
|
||||
}
|
||||
for (const originalSeq of shadowedSeqs) {
|
||||
const original = events[originalSeq]
|
||||
const original = events[originalSeq - baseSeq]
|
||||
if (original?.type !== 'tool/result') {
|
||||
throw new Error('tool/result surface replacement must target a current tool/result')
|
||||
}
|
||||
@@ -246,6 +276,7 @@ function planSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
baseSeq: number,
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
@@ -258,7 +289,7 @@ function planSurfaceEvent(
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
@@ -274,8 +305,9 @@ function applySurfaceEvent(
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
baseSeq: number,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events)
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -301,7 +333,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index, events)
|
||||
const replacement = applySurfaceEvent(state, event, index, events, 0)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
@@ -311,38 +343,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
|
||||
export class SurfaceManager implements SessionSurface {
|
||||
/** Shared transition state; replacement history is not retained. */
|
||||
private _state = createFoldState()
|
||||
/** Last processed seq; -1 folds a seeded log on first access. */
|
||||
private _lastProcessedSeq = -1
|
||||
/** Last processed absolute seq. */
|
||||
private _lastProcessedSeq: number
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
/**
|
||||
* @param log - Contiguous complete log or loaded event window.
|
||||
* @param baseSeq - Absolute sequence of the window's first event.
|
||||
*/
|
||||
constructor(
|
||||
private log: readonly SessionEvent[],
|
||||
private readonly baseSeq = 0,
|
||||
) {
|
||||
this._lastProcessedSeq = baseSeq - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the next candidate without mutating the committed surface.
|
||||
* @param event - candidate event that has not entered the log yet.
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length, this.log)
|
||||
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(
|
||||
this._state,
|
||||
event,
|
||||
this.baseSeq + this.log.length,
|
||||
this.log,
|
||||
this.baseSeq,
|
||||
)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
get replaceGeneration(): number {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
|
||||
return this._state.replaceGeneration
|
||||
}
|
||||
|
||||
/** Surface event sequences in model-visible order. */
|
||||
get nodes(): readonly number[] {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
|
||||
return this._state.nodes
|
||||
}
|
||||
|
||||
/** Fold events appended since the previous access. */
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
|
||||
this._lastProcessedSeq = i
|
||||
const tailSeq = this.baseSeq + this.log.length - 1
|
||||
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
|
||||
const index = seq - this.baseSeq
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
|
||||
this._lastProcessedSeq = seq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
AssistantMessage,
|
||||
CallId,
|
||||
LlmCallConfig,
|
||||
LlmCallConfigAdapterDefaults,
|
||||
LlmFailure,
|
||||
MessageSource,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
ToolResultMessage,
|
||||
@@ -58,6 +58,11 @@ export interface SessionHeader {
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
/**
|
||||
* Coarse product classification for a session created as a subagent child.
|
||||
* This is presentation metadata, not proof that the child is continuable.
|
||||
*/
|
||||
readonly origin?: 'subagent'
|
||||
/**
|
||||
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
|
||||
* for a subagent child. Persisted so a recursion budget survives restart and
|
||||
@@ -83,28 +88,20 @@ export interface CreateSessionOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly origin?: 'subagent'
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What started a turn.
|
||||
* Merge-extensible sum type (same pattern as MessageSourceMap).
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
/** Recovery turn reopened over the repaired current session log. */
|
||||
retry: { kind: 'retry' }
|
||||
/**
|
||||
* An out-of-band producer explicitly enclosed injected context in a one-shot
|
||||
* turn. `Agent.inject()` appends idle context directly and does not use this
|
||||
* trigger; the source mirrors the producer of the enclosed `user/message`.
|
||||
*/
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
/** Why an active agent driver was cancelled. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
| { readonly kind: 'hook'; readonly reason: string }
|
||||
| { readonly kind: 'disposed' }
|
||||
|
||||
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
|
||||
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
|
||||
|
||||
/**
|
||||
* Why a turn ended. Merge-extensible sum type.
|
||||
@@ -112,20 +109,15 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
/** A cancellation request interrupted the live turn. */
|
||||
aborted: { kind: 'aborted' }
|
||||
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
|
||||
|
||||
blocked: { kind: 'blocked' }
|
||||
/**
|
||||
* The turn failed: a step threw or the model reported a failure. `step` is the
|
||||
* step number the failure occurred on (the operational error's location — the
|
||||
* single durable record of an in-turn failure; live diagnostics also fire via
|
||||
* `agent/error`). Final model-request failures retain their normalized facts
|
||||
* as one `failure`; other thrown values retain their rendered message and a
|
||||
* real `HarnessError` code when present.
|
||||
* The turn failed. `error` is always a structured failure: the `LlmError`
|
||||
* facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
|
||||
* flattened from any other error.
|
||||
*/
|
||||
error: { kind: 'error'; step: number } & (
|
||||
| { failure: LlmFailure; message?: never; code?: never }
|
||||
| { message: string; code?: string; failure?: never }
|
||||
)
|
||||
disposed: { kind: 'disposed' }
|
||||
error: { kind: 'error'; error: LlmFailure }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
@@ -163,12 +155,24 @@ export interface TodoItem {
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
|
||||
config: LlmCallConfig
|
||||
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
|
||||
adapterDefaults?: LlmCallConfigAdapterDefaults
|
||||
/** Rendered system prompt text; absent for a system-less request. */
|
||||
system?: string
|
||||
/** Assembled tool schemas; absent for a tool-less request. */
|
||||
tools?: ToolSchema[]
|
||||
}
|
||||
|
||||
/** Registration-bound metadata for one resolved model route. */
|
||||
export interface RequestContext {
|
||||
/** Registered provider route the metadata belongs to. */
|
||||
provider: string
|
||||
/** Provider-owned model id the metadata belongs to. */
|
||||
model: string
|
||||
/** Maximum combined request and response context in tokens, when advertised. */
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
|
||||
* header (a new conversation); `'resume'` — a loop instance's first request
|
||||
@@ -185,14 +189,19 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started the model loop.
|
||||
* Opens turn `turn` before the loop claims queued input or runs pre-step.
|
||||
* Rejection, empty input, cancellation, or failure may close it with no
|
||||
* step; otherwise the following identified `user/message` event or batch
|
||||
* records the messages entering the step.
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
'turn/start': { turn: number }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn
|
||||
* with no entered step has no `step/start` or `step/end`. The loop does not await a
|
||||
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
|
||||
* per-request durability checkpoint, and consumers that read storage after
|
||||
* `whenIdle()` flush themselves. Success commits the turn; rejection is
|
||||
* reported live and does not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
@@ -203,9 +212,8 @@ export interface SessionEventMap {
|
||||
* A user-role message on the model-visible surface: a direct human prompt
|
||||
* (the queued message claimed for this turn), a synthetic `agent.inject()`
|
||||
* context (file-change notices, subdir AGENTS.md, skill content, cron
|
||||
* notifications, …), or an admitted goal continuation round. All three
|
||||
* project their `content` verbatim; `source` tells them apart. An idle
|
||||
* injection may append this event between turns without running the model.
|
||||
* notifications, …), or an entered goal continuation round. All three
|
||||
* project their `content` verbatim; `source` tells them apart.
|
||||
*/
|
||||
'user/message': UserMessage
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
@@ -241,8 +249,6 @@ export interface SessionEventMap {
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; message: UserMessage }
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
@@ -250,6 +256,34 @@ export interface SessionEventMap {
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Route metadata for the next request, logged only when the route or capacity
|
||||
* changes. It does not participate in request reconstruction or header equality.
|
||||
*/
|
||||
'request/context': RequestContext
|
||||
/**
|
||||
* Marks the end of a constructor seed. Events before it have smaller seq
|
||||
* values and came from the seed (resume, fork, or replay); this lifecycle
|
||||
* produced none of them. This log-only event is the durable projection of
|
||||
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
|
||||
* carry the meaning.
|
||||
*
|
||||
* Locate the LAST one in stored history. A seed already ending in one is not
|
||||
* re-marked, so reopening an untouched session does not grow its log per
|
||||
* pickup and the event need not be at the current `firstLiveSeq`.
|
||||
*
|
||||
* `Session`'s constructor is the only legitimate writer. The invariant
|
||||
* companion deliberately constrains nothing here, so a plugin appending one
|
||||
* would silently classify every live bracket before it as seed history.
|
||||
*
|
||||
* An owner of a standalone open/close bracket (`compact/start` …
|
||||
* `compact/end`) reads it because seed history and live work are otherwise
|
||||
* byte-identical: an unmatched opening marker before this event belongs to
|
||||
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
|
||||
* writers — a concurrently live session holds its own boundary elsewhere,
|
||||
* so tolerating concurrent writers needs a signal beyond the log.
|
||||
*/
|
||||
'session/end-seed': Record<string, never>
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
@@ -264,7 +298,6 @@ export type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'steering/message'
|
||||
|
||||
/**
|
||||
* A {@link SessionEvent} that is **on** the ordered surface — its
|
||||
@@ -281,7 +314,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
|
||||
* How a session event entered the ordered surface. Only valid on
|
||||
* {@link SurfaceEventType} events.
|
||||
*
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
@@ -316,7 +349,7 @@ export interface SurfaceIntent {
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
|
||||
Reference in New Issue
Block a user