Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload

# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/persistence-catalog.zh.md
#	packages/api/session-controller/src/commands.ts
#	packages/api/session-controller/src/index.ts
#	packages/api/session-controller/tests/client-contract.client.spec.ts
#	packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
#	packages/client/ui-attachment/src/AttachmentRail.module.css
#	packages/client/ui-chat/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-trajectory/README.i18n.yaml
#	packages/client/ui-trajectory/README.md
#	packages/client/ui-trajectory/README.zh.md
#	packages/extensions/cordis-client-runner/src/client/api-catalog.ts
#	packages/llm/token-meter/src/surface-fold.ts
#	packages/session-query/session-log-export/src/archive.ts
#	packages/session-query/session-log-export/tests/archive.host.spec.ts
This commit is contained in:
creatixchu
2026-09-02 10:29:53 +08:00
1010 changed files with 17789 additions and 15974 deletions
+8 -3
View File
@@ -9,7 +9,8 @@ import type {
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
@@ -112,7 +113,7 @@ export async function inspectApiSession(
ctx: Context,
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
): Promise<SessionInspection> {
try {
using observation = await ctx.sessionQuery.observeSession(sessionId, {
...(signal === undefined ? {} : { signal }),
@@ -121,7 +122,11 @@ export async function inspectApiSession(
if (observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: observation.header, events: [...observation.events] }
return {
meta: observation.header,
inheritedEventCount: observation.inheritedEventCount,
events: [...observation.events],
}
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
@@ -9,7 +9,7 @@
*/
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
@@ -131,7 +131,7 @@ export interface ISession {
* @param title - raw title text (the host normalizes acceptance).
* @returns the normalized accepted title and its event seq, or the business error.
*/
rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>>
rename(title: string): Promise<RemoteResult<{ title: string; seq: SessionSeq }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
@@ -145,7 +145,7 @@ export interface ISession {
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
* @returns completion once covered, exhausted, superseded, or failed soft.
*/
loadThrough(seq: number): Promise<void>
loadThrough(seq: SessionSeq): Promise<void>
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
@@ -3,7 +3,7 @@
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type {
SessionControlBaseline,
@@ -27,6 +27,10 @@ import { Session } from './session.ts'
import type { SessionRemotes } from './remotes.ts'
import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client'
function sessionSeqCursor(value: number): SessionSeqCursor {
return value === -1 ? -1 : SessionSeq(value)
}
/**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
* `pending` (no successful pull yet — an empty items array means "nothing
@@ -501,7 +505,7 @@ export class SessionManager {
if (block === undefined) continue
const store = this.projectionStore(s.sessionId)
const values = block.values as Record<string, unknown>
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
for (const key of Object.keys(values)) store.apply(key, values[key], sessionSeqCursor(block.asOfSeq))
}
} else {
this.listState = 'error'
@@ -593,7 +597,7 @@ export class SessionManager {
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
opts: { sessionId: SessionId; atSeq?: SessionSeq },
): Promise<RemoteResult<{ sessionId: SessionId }>> {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const result = await this.remote.session.fork({
@@ -664,7 +668,7 @@ export class SessionManager {
return
}
if (frame.type === 'projection') {
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq))
this.notifier.markDirty()
return
}
@@ -691,8 +695,9 @@ export class SessionManager {
for (const [sessionId, block] of Object.entries(baseline.projections)) {
const store = this.projectionStore(sessionId as SessionId)
store.truncate(block.asOfSeq)
store.seed(block)
const asOfSeq = sessionSeqCursor(block.asOfSeq)
store.truncate(asOfSeq)
store.seed({ ...block, asOfSeq })
}
for (const [sessionId, session] of this.sessions) {
session.replaceControl(this.queues.get(sessionId) ?? [])
@@ -711,7 +716,7 @@ export class SessionManager {
if (projections !== undefined) {
const store = this.projectionStore(summary.sessionId)
for (const [key, value] of Object.entries(projections.values)) {
store.apply(key, value, projections.asOfSeq)
store.apply(key, value, sessionSeqCursor(projections.asOfSeq))
}
}
if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
@@ -9,6 +9,7 @@
* bare observable faces feed `useProjection` (ui-renderer binds them).
*/
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { Notifier } from './notifier.ts'
@@ -46,7 +47,7 @@ export type UseProjection = {
*/
export interface ProjectionsBaseline {
/** The consistent-cut seq (equals the window tail seq by construction). */
asOfSeq: number
asOfSeq: SessionSeqCursor
/** Whole current values by key; a registered key absent here means the capability is absent. */
values: Readonly<Record<string, unknown>>
}
@@ -54,7 +55,7 @@ export interface ProjectionsBaseline {
/** One key's row: the latest finished value and the seq it is consistent with. */
interface Row {
value: unknown
seq: number
seq: SessionSeqCursor
}
/** Per-key notification channel: the bare face plus its batching notifier. */
@@ -130,7 +131,7 @@ export class ProjectionValueStore {
* @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 {
apply(key: string, value: unknown, seq: SessionSeqCursor): 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 })
@@ -165,7 +166,7 @@ export class ProjectionValueStore {
* baseline immediately afterward.
* @param lastSeq - highest durable sequence reflected by the baseline.
*/
truncate(lastSeq: number): void {
truncate(lastSeq: SessionSeqCursor): void {
for (const [key, row] of this.rows) {
if (row.seq <= lastSeq) continue
this.rows.delete(key)
@@ -16,7 +16,7 @@
*/
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
@@ -441,7 +441,7 @@ export class ClientSessions implements ISessions {
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(Math.floor(opts.atSeq)) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
@@ -8,7 +8,7 @@ import type {
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionEventStream } from '../transport.ts'
import type { SessionJournalChange } from '../transport.ts'
import type {
@@ -16,6 +16,7 @@ import type {
QueueAction,
SessionAddress,
SessionControlFrame,
SessionProjectionBaseline,
SessionQueuedItem,
SessionRequestId,
SessionUploadFileValue,
@@ -41,6 +42,13 @@ import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline {
return {
...value,
asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq),
}
}
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
@@ -80,7 +88,7 @@ export interface SessionOptions {
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private baseSeq = 0
private baseSeq = SessionLogOffset(0)
private hasMore = false
private openState: OpenState = 'cold'
private openError: RemoteFailure | null = null
@@ -90,7 +98,7 @@ export class Session implements SessionFace {
private openGeneration = 0
private loadingOlder = false
/** Shared low-water target of the running jump loop; null when no jump is paging. */
private jumpTargetSeq: number | null = null
private jumpTargetSeq: SessionSeq | null = null
/** The running jump loop's completion, shared by retargeting callers. */
private jumpPromise: Promise<void> | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
@@ -392,10 +400,12 @@ export class Session implements SessionFace {
* @param title - raw title text (the host normalizes acceptance).
* @returns the rename result (normalized accepted title + title event seq).
*/
async rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>> {
async rename(title: string): Promise<RemoteResult<{ title: string; seq: SessionSeq }>> {
const result = await this.remote.session.rename({ sessionId: this.sessionId, title })
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
if (!result.ok) return result
const seq = SessionSeq(result.value.seq)
this.projections.apply('title', result.value.title, seq)
return { ok: true, value: { title: result.value.title, seq } }
}
/**
@@ -443,11 +453,11 @@ export class Session implements SessionFace {
}
/** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
loadThrough(seq: number): Promise<void> {
loadThrough(seq: SessionSeq): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve()
if (this.jumpPromise !== null) {
// Retarget the running loop to the lowest requested seq.
this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq)
this.jumpTargetSeq = SessionSeq(Math.min(this.jumpTargetSeq ?? seq, seq))
return this.jumpPromise
}
// A plain single-page pull owns the busy flag; the jump does not queue
@@ -500,7 +510,7 @@ export class Session implements SessionFace {
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.baseSeq = 0
this.baseSeq = SessionLogOffset(0)
this.notifier.markDirty()
await this.open()
}
@@ -672,7 +682,11 @@ export class Session implements SessionFace {
private acceptEventChange(change: SessionJournalChange): void {
switch (change.type) {
case 'replace':
this.installWindow(change.entries, change.hasMore, change.page.projections)
this.installWindow(
change.entries,
change.hasMore,
change.page.projections === undefined ? undefined : projectionsBaseline(change.page.projections),
)
return
case 'prepend':
this.prependWindow(change.entries, change.hasMore)
@@ -684,7 +698,7 @@ export class Session implements SessionFace {
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.baseSeq = entries[0]?.event.seq ?? 0
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
this.hasMore = hasMore
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
@@ -695,7 +709,7 @@ export class Session implements SessionFace {
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.baseSeq = entries[0]?.event.seq ?? this.baseSeq
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
}
@@ -11,6 +11,7 @@ import {
ReasoningEffortId, createUserMessage, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
@@ -311,9 +312,11 @@ export class SessionCommandController {
* @returns the new Session identity.
*/
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
if (request.atSeq !== undefined
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {})
let atSeq: ReturnType<typeof SessionSeq> | undefined
try {
atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq)
} catch {
throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative safe integer', {})
}
let observed: SessionObservation
try {
@@ -333,7 +336,6 @@ export class SessionCommandController {
}
using source = observed
const lastSeq = source.events.at(-1)?.seq ?? -1
const atSeq = request.atSeq
const anchoredBoundary = atSeq === undefined
? undefined
: source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
@@ -350,8 +352,10 @@ export class SessionCommandController {
{ sessionId: request.sessionId },
)
}
let cut = boundary.seq + 1
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
let cut = SessionLogOffset(boundary.seq + 1)
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') {
cut = SessionLogOffset(cut + 1)
}
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source.header)
@@ -369,10 +373,11 @@ export class SessionCommandController {
await this.ctx.agents.create({
sessionId: childId,
seed: source.events.slice(0, cut),
inheritedEventCount: cut,
meta: {
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
parentSession: source.header.id,
seedLength: cut,
isSeeded: true,
...(composition.agentPreset === undefined
? {}
: { agentPreset: composition.agentPreset }),
+70 -28
View File
@@ -2,9 +2,19 @@
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import {
isAppendSurfaceEvent,
SessionLogOffset,
SessionSeq,
} from '@deepseek-ai/dsh-session'
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionEvent,
SessionHeader,
SessionId,
SessionLogOffset as SessionLogOffsetType,
SessionSeqCursor,
} from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
@@ -19,6 +29,7 @@ import type {
SessionPageRequest,
SessionProjectionBaseline,
SessionProjectionValues,
SessionWireHeader,
SessionWireEvent,
} from './types.ts'
@@ -51,26 +62,32 @@ export class SessionHistoryController {
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
validatePageRequest(request)
const throughSeq: SessionSeqCursor = request.throughSeq === -1
? -1
: SessionSeq(request.throughSeq)
const beforeSeq = request.beforeSeq === undefined
? undefined
: SessionLogOffset(request.beforeSeq)
using source = await this.sourceFor(request.address, signal, false)
signal.throwIfAborted()
const sourceLog = source.events
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
if (request.throughSeq > sourceCursor) {
const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1
if (throughSeq > sourceCursor) {
throw new RemoteError(
'gateway/bad-request',
`session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
`session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`,
{},
)
}
/* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) {
throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {})
}
const page = paginate(
sourceLog,
request.beforeSeq,
beforeSeq,
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
request.throughSeq,
throughSeq,
)
const records = pageRecords(page.events)
return {
@@ -90,7 +107,7 @@ export class SessionHistoryController {
const { address } = request
const target = addressId(address)
const buffered = new Deque<SessionEvent>()
let snapshotCursor: number | undefined
let snapshotCursor: SessionSeqCursor | undefined
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
@@ -115,7 +132,7 @@ export class SessionHistoryController {
// opening observation, replay everything beyond that snapshot cursor.
const suffix = session.snapshotEvents(snapshotCursor === undefined
? session.firstLiveSeq
: snapshotCursor + 1)
: SessionLogOffset(snapshotCursor + 1))
for (let index = suffix.length - 1; index >= 0; index -= 1) {
buffered.pushFront(suffix[index] as SessionEvent)
}
@@ -132,7 +149,7 @@ export class SessionHistoryController {
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
yield {
type: 'snapshot',
header: source.header,
header: wireHeader(source.header, source.inheritedEventCount),
cursor,
records: pageRecords(page.events),
hasMore: page.hasMore,
@@ -149,18 +166,19 @@ export class SessionHistoryController {
throw error
}
}
let nextSeq = cursor + 1
let nextOffset = SessionLogOffset(cursor + 1)
while (!follower.closed && !signal.aborted) {
const item = buffered.popFront()
if (item === undefined) {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {})
const expectedSeq = SessionSeq(nextOffset)
if (item.seq < expectedSeq) continue
if (item.seq !== expectedSeq) {
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {})
}
nextSeq++
nextOffset = SessionLogOffset(nextOffset + 1)
yield entryFor(item)
}
} finally {
@@ -187,7 +205,12 @@ export class SessionHistoryController {
rejectNotFound(address)
}
try {
validateAddress(address, observation.header, observation.projections)
validateAddress(
address,
observation.header,
observation.inheritedEventCount,
observation.projections,
)
} catch (error: unknown) {
observation[Symbol.dispose]()
throw error
@@ -213,11 +236,15 @@ function projectionBlock(
}
function validatePageRequest(request: SessionPageRequest): void {
if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
if (!Number.isSafeInteger(request.throughSeq)
|| request.throughSeq < -1
|| Object.is(request.throughSeq, -0)) {
throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
}
if (request.beforeSeq !== undefined
&& (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
&& (!Number.isSafeInteger(request.beforeSeq)
|| request.beforeSeq < 0
|| Object.is(request.beforeSeq, -0))) {
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
}
if (request.maxMessages !== undefined
@@ -240,6 +267,7 @@ function addressId(address: SessionAddress): SessionId {
function validateAddress(
address: SessionAddress,
header: SessionHeader,
inheritedEventCount: SessionLogOffsetType,
projections: SessionObservation['projections'],
): void {
if (address.kind === 'session') {
@@ -263,7 +291,7 @@ function validateAddress(
reason: 'corrupt',
})
}
if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
if (identity === undefined || identity.seq < inheritedEventCount) {
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
@@ -289,30 +317,44 @@ function rejectNotFound(address: SessionAddress): never {
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
beforeSeq: SessionLogOffsetType | undefined,
maxMessages: number,
throughSeq = events.at(-1)?.seq ?? -1,
throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)
const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
let count = 0
let cut = 0
let cut = SessionLogOffset(0)
for (let index = end - 1; index >= 0; index--) {
const event = events[index] as SessionEvent
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
const sources = event.sourceEventSeqs
let groupStart = event.seq
if (sources !== undefined) {
for (const source of sources) groupStart = Math.min(groupStart, source)
for (const source of sources) {
if (source < groupStart) groupStart = source
}
}
if (count >= maxMessages) {
cut = groupStart
cut = SessionLogOffset(groupStart)
break
}
}
return { events: events.slice(cut, end), hasMore: cut > 0 }
}
/** Translate logical Session metadata to the unchanged v0 browser wire. */
function wireHeader(
header: SessionHeader,
inheritedEventCount: SessionLogOffsetType,
): SessionWireHeader {
const { isSeeded, ...wire } = header
return {
...wire,
...isSeeded ? { seedLength: inheritedEventCount } : {},
}
}
function entryFor(event: SessionEvent): SessionEventEntry {
return {
type: 'event',
+23 -10
View File
@@ -4,7 +4,8 @@ import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
@@ -17,7 +18,11 @@ import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { SessionFileReferences } from './file-references.ts'
import { registerSessionFileUploadHttp } from './file-upload-http.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import {
ApiSessionList,
DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS,
} from './list.ts'
import { buildModelCatalog } from './catalog.ts'
import { installModelSelectionProjection } from './model-selection-projection.ts'
import { SessionSkillCatalog } from './skill-catalog.ts'
@@ -68,7 +73,9 @@ declare module '@deepseek-ai/cordis' {
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size eligible for one full projection observation. */
/** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */
readonly coldBlankProbeMaxEvents?: number
/** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
@@ -97,6 +104,7 @@ export class SessionController extends TypertRemoteService {
]
static Config: z<Config> = z.object({
coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS),
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
nativeOpen: z.boolean(),
})
@@ -112,7 +120,8 @@ export class SessionController extends TypertRemoteService {
/**
* @param ctx - Host context containing the Session capability assembly.
* @param config - cold-list observation policy.
* @param config - cold-list observation and native-opener deployment policy.
* @param internals - host integrations replaceable by direct unit tests.
*/
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
super(ctx, 'sessionController', { namespace: 'session' })
@@ -127,10 +136,10 @@ export class SessionController extends TypertRemoteService {
await Promise.allSettled([...this.promotions])
}, 'session-controller.promotions')
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
this.listState = new ApiSessionList(
ctx,
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
)
this.listState = new ApiSessionList(ctx, {
coldBlankProbeMaxEvents: config.coldBlankProbeMaxEvents ?? DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS,
coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
})
this.openPath = internals.openPath ?? openNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
@@ -200,10 +209,14 @@ export class SessionController extends TypertRemoteService {
inspect(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
): Promise<SessionInspection> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
return Promise.resolve({ meta: attached.header, events: attached.snapshotEvents() })
return Promise.resolve({
meta: attached.header,
inheritedEventCount: attached.inheritedEventCount,
events: attached.snapshotEvents(),
})
}
return inspectApiSession(this.ctx, sessionId, signal)
}
+39 -11
View File
@@ -1,9 +1,9 @@
/** Cold-safe Session list and search projection. */
import { stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
@@ -19,9 +19,20 @@ import type {
SessionSearchValue, SessionSummary,
} from './types.ts'
/** Default maximum artifact size eligible for one cold projection observation. */
/** Default maximum stat-reported event count eligible for one cold projection observation. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS = 16
/** Default maximum stat-reported artifact size eligible for one cold projection observation. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
/** Resolved cold-blank probe policy: each threshold gates its stat metric; `0` disables that gate. */
export interface ColdBlankProbePolicy {
/** Maximum stat-reported `eventCount` eligible for a full observation. */
readonly coldBlankProbeMaxEvents: number
/** Maximum stat-reported `sizeBytes` eligible for a full observation. */
readonly coldBlankProbeMaxBytes: number
}
const COLD_SUMMARY_BATCH_SIZE = 16
const SEARCH_PROVIDER_CALL_LIMIT = 100
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
@@ -81,11 +92,11 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin
export class ApiSessionList {
/**
* @param ctx - Host context carrying Session, query, persistence, and projection services.
* @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation.
* @param probe - stat-metadata thresholds gating a full cold observation.
*/
constructor(
private readonly ctx: Context,
private readonly coldBlankProbeMaxBytes: number,
private readonly probe: ColdBlankProbePolicy,
) {
ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
@@ -175,7 +186,7 @@ export class ApiSessionList {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
running: false,
// A large or inaccessible cache miss remains unknown and visible.
// A large, metadata-less, or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
...listFields(header),
...(projections === undefined ? {} : { projections }),
@@ -186,15 +197,30 @@ export class ApiSessionList {
header: SessionHeader,
signal: AbortSignal | undefined,
): Promise<SessionProjectionHints | undefined> {
if (this.coldBlankProbeMaxBytes === 0) return undefined
const { coldBlankProbeMaxEvents, coldBlankProbeMaxBytes } = this.probe
if (coldBlankProbeMaxEvents === 0 && coldBlankProbeMaxBytes === 0) return undefined
const persistence = this.ctx.get('sessionPersistence')
const location = persistence?.locate(header)
if (location === undefined) return undefined
if (persistence === undefined) return undefined
signal?.throwIfAborted()
let snapshot: Awaited<ReturnType<typeof persistence.stat>>
try {
if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined
} catch {
snapshot = await persistence.stat(header.id, signal === undefined ? {} : { signal })
} catch (error: unknown) {
// An unreadable single session degrades to unknown state instead of
// failing the whole list request.
signal?.throwIfAborted()
this.ctx.logger.warn(
`api-session.list: cold stat for "${header.id}" failed; serving it as visible: ${String(error)}`,
)
return undefined
}
if (snapshot === undefined) return undefined
if (snapshot.eventCount !== undefined) {
if (coldBlankProbeMaxEvents === 0 || snapshot.eventCount > coldBlankProbeMaxEvents) return undefined
} else if (snapshot.sizeBytes !== undefined) {
if (coldBlankProbeMaxBytes === 0 || snapshot.sizeBytes > coldBlankProbeMaxBytes) return undefined
} else {
// The backend offers no cheap size hint, so a full observation is unbounded work.
return undefined
}
try {
@@ -329,7 +355,9 @@ export class ApiSessionList {
): SessionProjectionHints | undefined {
try {
const block = session === undefined
? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
? header.isSeeded
? undefined
: this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, SessionLogOffset(0))
: this.ctx.sessionProjections.cachedSnapshot(session)
return block !== undefined && Object.keys(block.values).length > 0
? {
+22 -3
View File
@@ -7,7 +7,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -408,6 +408,25 @@ export interface SessionEventEntry {
readonly event: SessionWireEvent
}
/** v0-compatible Session metadata carried on the browser wire. */
export interface SessionWireHeader {
readonly version: number
readonly id: SessionId
readonly createdAt: number
readonly cwd?: string
readonly parentSession?: SessionId
/** Exact inherited prefix length; absent for an unseeded Session. */
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
readonly agentPreset?: string
}
/** Browser wire form of one Session surface operation. */
export type SessionWireSurfaceOp =
| 'append'
| { readonly op: 'replace'; readonly start: number; readonly end: number }
/** Event-shaped wire representation of one packed chunk row. */
export type ChunkRowEvent = {
[Kind in ChunkRow['type']]: {
@@ -435,7 +454,7 @@ export interface SessionWireEvent {
readonly data: JsonValue
readonly ignorable?: true
readonly sourceEventSeqs?: number[]
readonly surfaceOp?: SurfaceOp
readonly surfaceOp?: SessionWireSurfaceOp
}
/** One message-aligned backwards-history request. */
@@ -463,7 +482,7 @@ export interface SessionPage {
export type SessionFollowFrame =
| {
readonly type: 'snapshot'
readonly header: SessionHeader
readonly header: SessionWireHeader
readonly cursor: number
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean