mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
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:
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user