Merge origin/master into schedule-web-catalog

This commit is contained in:
pku-xht
2026-08-25 20:52:51 +08:00
369 changed files with 10402 additions and 1888 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
README.md: 510f336c76dd80ed01bdd2bd4a364106f418831f
README.zh.md: 4503a8f9d0bcfc7f669f00cbc4db69e0d3efd3cd
+2 -2
View File
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
## Model Experience
+2 -2
View File
@@ -4,11 +4,11 @@
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }``type: 'event'` 携带一个原始 `SessionWireEvent``type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type``seq``time``data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq``time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistencequeue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
## 模型体验
@@ -1,10 +1,22 @@
/** Observable contiguous Session event window consumed by domain assemblers. */
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionEventEntry } from '../../types.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ChunkRowEvent } from '../../types.ts'
/** Standard Session event or compact historical Assistant run. */
export type SessionEventLike = SessionEvent | ChunkRowEvent
/** Client history entry retaining its coarse transport discriminator. */
export type SessionEventLikeEntry =
| { readonly type: 'event'; readonly event: SessionEvent }
| { readonly type: 'chunks'; readonly event: ChunkRowEvent }
/** Scalar live entry accepted by append-only Client paths. */
export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'event' }>
interface EventWindowLeaf {
readonly kind: 'leaf'
readonly entries: readonly SessionEventEntry[]
readonly entries: readonly SessionEventLikeEntry[]
readonly length: number
}
@@ -17,7 +29,7 @@ interface EventWindowConcat {
type EventWindowNode = EventWindowLeaf | EventWindowConcat
function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf {
function leaf(entries: readonly SessionEventLikeEntry[]): EventWindowLeaf {
return { kind: 'leaf', entries, length: entries.length }
}
@@ -25,9 +37,9 @@ function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConca
return { kind: 'concat', left, right, length: left.length + right.length }
}
function materialize(node: EventWindowNode): readonly SessionEventEntry[] {
function materialize(node: EventWindowNode): readonly SessionEventLikeEntry[] {
if (node.kind === 'leaf') return node.entries
const entries = new Array<SessionEventEntry>(node.length)
const entries = new Array<SessionEventLikeEntry>(node.length)
const pending: EventWindowNode[] = [node]
let index = 0
while (pending.length > 0) {
@@ -50,7 +62,7 @@ function windowSnapshot(
revision: number,
change: SessionEventChange,
): SessionEventWindow {
let entries: readonly SessionEventEntry[] | undefined
let entries: readonly SessionEventLikeEntry[] | undefined
return {
get entries() {
entries ??= materialize(node)
@@ -64,13 +76,13 @@ function windowSnapshot(
/** Exact delta that produced the latest event-window revision. */
export type SessionEventChange =
| { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionLiveEventEntry[] }
/** Current contiguous event window and its latest synchronous delta. */
export interface SessionEventWindow {
readonly entries: readonly SessionEventEntry[]
readonly entries: readonly SessionEventLikeEntry[]
readonly hasMore: boolean
readonly revision: number
readonly change: SessionEventChange
@@ -108,7 +120,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* @param entries - complete window.
* @param hasMore - whether older history remains.
*/
replace(entries: readonly SessionEventEntry[], hasMore: boolean): void {
replace(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.window = leaf(entries)
this.publish(hasMore, { kind: 'replace', entries })
}
@@ -118,7 +130,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* @param entries - newly loaded older entries.
* @param hasMore - whether still older history remains.
*/
prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void {
prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.window = concat(leaf(entries), this.window)
this.publish(hasMore, { kind: 'prepend', entries })
}
@@ -127,7 +139,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* Append one contiguous live entry.
* @param entry - live tail entry.
*/
append(entry: SessionEventEntry): void {
append(entry: SessionLiveEventEntry): void {
const entries = [entry]
this.window = concat(this.window, leaf(entries))
this.publish(this.snapshot.hasMore, {
@@ -43,7 +43,14 @@ export type {
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export { MutableSessionEventSource } from './contract/events.ts'
export type { SessionEventChange, SessionEventSource, SessionEventWindow } from './contract/events.ts'
export type {
SessionEventChange,
SessionEventLike,
SessionEventLikeEntry,
SessionEventSource,
SessionEventWindow,
SessionLiveEventEntry,
} from './contract/events.ts'
export type {
OpenState,
PromptError,
@@ -0,0 +1,39 @@
/** Client range access and type narrowing for aligned Session history records. */
import type {
SessionHistoryRecord,
} from '../../types.ts'
import type { SessionEventLikeEntry } from '../contract/events.ts'
/**
* Narrow aligned wire records to their Client event types without allocation.
* @param records - validated history transport records.
* @returns the same record array with typed inner events.
*/
export function historyEntries(
records: readonly SessionHistoryRecord[],
): readonly SessionEventLikeEntry[] {
return records as unknown as readonly SessionEventLikeEntry[]
}
/**
* Read the first logical sequence represented by one wire record.
* @param record - validated scalar event or packed Assistant delta run.
* @returns inclusive first Session sequence.
*/
export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
return record.event.seq
}
/**
* Read the final logical sequence represented by one wire record.
* @param record - validated scalar event or packed Assistant delta run.
* @returns inclusive final Session sequence.
*/
export function historyRecordLastSeq(record: SessionHistoryRecord): number {
if (record.type === 'event') return record.event.seq
const length = record.event.type === 'chunkrow/tool-call-chunks'
? record.event.data.args.length
: record.event.data.texts.length
return record.event.seq + length - 1
}
@@ -7,7 +7,7 @@ import type {
IApiClient, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
SessionEventStream,
sessionStreamFailure,
@@ -18,7 +18,6 @@ import type {
QueueAction,
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionQueuedItem,
SessionRequestId,
SessionError,
@@ -30,6 +29,9 @@ import type {
OpenState, PromptError, SessionSnapshot,
} from '../contract/snapshot.ts'
import { MutableSessionEventSource } from '../contract/events.ts'
import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '../contract/events.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemotes } from './remotes.ts'
@@ -72,7 +74,6 @@ export interface SessionOptions {
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -400,7 +401,6 @@ export class Session implements SessionFace {
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.eventWindow = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
@@ -575,28 +575,25 @@ export class Session implements SessionFace {
}
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.baseSeq = entries[0]?.event.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(entries, hasMore)
this.notifier.markDirty()
}
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.baseSeq = entries[0]?.event.seq ?? this.baseSeq
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
}
/** Append one stream-validated live event. */
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
private appendLive(entry: SessionLiveEventEntry): boolean {
const event = entry.event
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
@@ -14,11 +14,17 @@ import {
import type {
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
} from '../types.ts'
import {
historyEntries,
historyRecordFirstSeq,
historyRecordLastSeq,
} from './sessions/history-records.ts'
import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts'
export {
SESSION_SEARCH_RESULT_LIMIT,
@@ -37,7 +43,33 @@ interface SessionJournalPage extends SessionPage {
}
/** One complete publication from the Session journal stream. */
export type SessionJournalChange = RemoteJournalChange<SessionJournalPage, SessionEventEntry>
export type SessionJournalChange =
| {
readonly type: 'replace' | 'prepend'
readonly page: SessionJournalPage
readonly entries: readonly SessionEventLikeEntry[]
readonly hasMore: boolean
}
| { readonly type: 'append'; readonly entry: SessionLiveEventEntry }
function toSessionJournalChange(
change: RemoteJournalChange<SessionJournalPage, SessionHistoryRecord>,
): SessionJournalChange {
switch (change.type) {
case 'replace':
case 'prepend':
return { ...change, entries: historyEntries(change.entries) }
case 'append': {
if (change.entry.type !== 'event') {
throw new Error('session live stream emitted a packed history record')
}
return {
type: 'append',
entry: change.entry as unknown as SessionLiveEventEntry,
}
}
}
}
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
@@ -100,7 +132,7 @@ export function createSessionControlStream(
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
export class SessionEventStream extends RemoteJournalStream<
SessionJournalPage,
SessionEventEntry,
SessionHistoryRecord,
number,
ClientSessionPageRequest
> {
@@ -117,12 +149,13 @@ export class SessionEventStream extends RemoteJournalStream<
super(remote, {
name: 'session event stream',
emptyCursor: -1,
entries: page => page.events,
entries: page => page.records,
hasMore: page => page.hasMore,
cursor: entry => entry.event.seq,
first: historyRecordFirstSeq,
last: historyRecordLastSeq,
compare: (left, right) => left - right,
follows: (left, right) => right === left + 1,
publish: options.publish,
publish: (change) => { options.publish(toSessionJournalChange(change)) },
...(options.carrierFailed === undefined
? {}
: { carrierFailed: options.carrierFailed }),
@@ -134,7 +167,7 @@ export class SessionEventStream extends RemoteJournalStream<
protected override async * follow(
request: ClientSessionPageRequest,
signal: AbortSignal,
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number, SessionJournalPage>> {
): AsyncIterable<RemoteJournalFrame<SessionHistoryRecord, number, SessionJournalPage>> {
for await (const frame of this.remote.session.follow({
address: this.address,
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
@@ -144,15 +177,14 @@ export class SessionEventStream extends RemoteJournalStream<
type: 'opened',
cursor: frame.cursor,
page: {
events: frame.events,
records: frame.records,
hasMore: frame.hasMore,
projections: frame.projections,
},
}
continue
}
const { type: _type, ...entry } = frame
yield { type: 'entry', entry }
yield { type: 'entry', entry: frame }
}
}
+35 -4
View File
@@ -2,15 +2,18 @@
import type { Context } from '@deepseek-ai/cordis'
import { isAppendSurfaceEvent } 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 { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
SessionChunkRun,
SessionEventEntry,
SessionFollowRequest,
SessionFollowFrame,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
@@ -68,9 +71,9 @@ export class SessionHistoryController {
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
request.throughSeq,
)
const entries = page.events.map(entryFor)
const records = pageRecords(page.events)
return {
events: entries,
records,
hasMore: page.hasMore,
}
}
@@ -128,7 +131,7 @@ export class SessionHistoryController {
type: 'snapshot',
header: source.header,
cursor,
events: page.events.map(entryFor),
records: pageRecords(page.events),
hasMore: page.hasMore,
projections: source.projections === undefined
? { asOfSeq: cursor, values: {} }
@@ -155,7 +158,7 @@ export class SessionHistoryController {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield { type: 'event', ...entryFor(item) }
yield entryFor(item)
}
} finally {
this.closeFollowers.delete(close)
@@ -313,7 +316,35 @@ function paginate(
function entryFor(event: SessionEvent): SessionEventEntry {
return {
type: 'event',
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
}
}
function chunkEntryFor(row: ChunkRow): SessionChunkRun {
switch (row.type) {
case 'text-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'reasoning-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'tool-call-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
}
}
/** Encode one bounded logical page without changing its pagination cut. */
function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
return packChunkRuns(events).map(record => isChunkRow(record)
? chunkEntryFor(record)
: entryFor(record))
}
+24 -3
View File
@@ -6,6 +6,7 @@ import type {
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 { JsonValue, SessionHeader, SessionId, SurfaceOp } 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'
@@ -361,9 +362,29 @@ export type SessionAddress =
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly type: 'event'
readonly event: SessionWireEvent
}
/** Event-shaped wire representation of one packed chunk row. */
export type ChunkRowEvent = {
[Kind in ChunkRow['type']]: {
readonly type: `chunkrow/${Kind}`
readonly seq: number
readonly time: number
readonly data: Extract<ChunkRow, { readonly type: Kind }>['data']
}
}[ChunkRow['type']]
/** One lossless run of consecutive Assistant delta events in a history page. */
export interface SessionChunkRun {
readonly type: 'chunks'
readonly event: ChunkRowEvent
}
/** One history-page record: a raw event or a packed Assistant delta run. */
export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
export interface SessionWireEvent {
readonly type: string
@@ -392,7 +413,7 @@ export interface SessionFollowRequest {
/** One contiguous backwards page of a Session log. */
export interface SessionPage {
readonly events: readonly SessionEventEntry[]
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
}
@@ -402,11 +423,11 @@ export type SessionFollowFrame =
readonly type: 'snapshot'
readonly header: SessionHeader
readonly cursor: number
readonly events: readonly SessionEventEntry[]
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
readonly projections: SessionProjectionBaseline
}
| ({ readonly type: 'event' } & SessionEventEntry)
| SessionEventEntry
/** One pending inbox occurrence in the authoritative queue snapshot. */
export interface SessionQueuedItem {
@@ -1,16 +1,17 @@
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import { describe, expect, it, vi } from 'vitest'
import { MutableSessionEventSource } from '../src/client/contract/events.ts'
import {
MutableSessionEventSource, type SessionLiveEventEntry,
} from '../src/client/contract/events.ts'
import { transportResult } from '../src/client/contract/result.ts'
function entry(seq: number): SessionEventEntry {
function entry(seq: number): SessionLiveEventEntry {
return {
type: 'event',
event: {
type: 'fixture/event',
type: 'turn/start',
seq,
time: seq,
data: { seq },
ignorable: true,
data: { turn: seq },
},
}
}
@@ -5,6 +5,11 @@ import {
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionEventEntry,
SessionPage,
SessionWireEvent,
} from '../src/types.ts'
/** One text content block (local helper). */
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
@@ -143,6 +148,14 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
}
/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
export function entries(events: readonly SessionEvent[]): SessionEventEntry[] {
return events.map(event => ({ type: 'event', event: event as unknown as SessionWireEvent }))
}
/** Build one view-less history response value. */
export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage {
return {
records: entries(events),
hasMore,
}
}
@@ -28,6 +28,7 @@ import {
} from '@deepseek-ai/dsh-api-gateway/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
const AVAILABLE_STREAM_CONNECTION = {
hostDescription: {
@@ -137,7 +138,7 @@ export class FakeApiClient implements IApiClient {
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
() => Promise.resolve(ok({ records: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
@@ -439,7 +440,8 @@ export class FakeApiClient implements IApiClient {
ok: true,
value: {
...result.value,
events: result.value.events.filter(entry => entry.event.seq <= request.throughSeq),
records: result.value.records
.filter(record => historyRecordLastSeq(record) <= request.throughSeq),
},
}
}
@@ -467,7 +469,8 @@ export class FakeApiClient implements IApiClient {
)
}
const page = response.result.value
const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1
const tail = page.records.at(-1)
const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
yield {
type: 'snapshot',
header: {
@@ -479,7 +482,7 @@ export class FakeApiClient implements IApiClient {
: {}),
},
cursor,
events: page.events.filter(entry => entry.event.seq <= cursor),
records: page.records.filter(record => historyRecordLastSeq(record) <= cursor),
hasMore: page.hasMore,
projections: page.projections ?? { asOfSeq: cursor, values: {} },
}
@@ -0,0 +1,78 @@
/** Packed history records become one event-shaped Client value per wire record. */
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionHistoryRecord } from '../src/types.ts'
import {
historyEntries,
historyRecordFirstSeq,
historyRecordLastSeq,
} from '../src/client/sessions/history-records.ts'
describe('Session history record projection', () => {
it('retains an ordinary event and its point cursor', () => {
const ordinary: SessionHistoryRecord = {
type: 'event',
event: { type: 'turn/start', seq: 7, time: 1, data: { turn: 1 } },
}
const records = [ordinary]
const [entry] = historyEntries(records)
expect(historyEntries(records)).toBe(records)
expect(entry).toBe(ordinary)
expect(historyRecordFirstSeq(ordinary)).toBe(7)
expect(entry?.event.time).toBe(1)
expect(historyRecordLastSeq(ordinary)).toBe(7)
})
it('retains one packed text row without copying or reshaping it', () => {
const packed: SessionHistoryRecord = {
type: 'chunks',
event: {
type: 'chunkrow/text-chunks',
seq: 11,
time: 20,
data: { turn: 1, step: 2, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] },
},
}
const [entry] = historyEntries([packed])
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
const { event } = entry
expect(entry).toBe(packed)
expect(event).toBe(packed.event)
expect(historyRecordFirstSeq(packed)).toBe(11)
expect(event.time).toBe(20)
expect(historyRecordLastSeq(packed)).toBe(14)
})
it('preserves a packed tool-call row and optional-name absence', () => {
const packed: SessionHistoryRecord = {
type: 'chunks',
event: {
type: 'chunkrow/tool-call-chunks',
seq: 20,
time: 200,
data: {
turn: 2,
step: 4,
index: 1,
id: CallId('call-1'),
dt: [2, 3],
args: ['', '{"x":', '1}'],
},
},
}
const [entry] = historyEntries([packed])
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
const { event } = entry
if (event.type !== 'chunkrow/tool-call-chunks') throw new Error('expected packed history event')
expect(event).toBe(packed.event)
expect(Object.hasOwn(event.data, 'name')).toBe(false)
expect(historyRecordLastSeq(packed)).toBe(22)
})
})
@@ -759,7 +759,7 @@ describe('connected generation', () => {
it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
records: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
@@ -122,7 +122,7 @@ describe('Session tail-page seeding', () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
} as never))
await session.open()
@@ -133,7 +133,7 @@ describe('Session tail-page seeding', () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
} as never))
await session.open()
@@ -145,7 +145,7 @@ describe('Session tail-page seeding', () => {
it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
await session.resync()
@@ -390,7 +390,7 @@ describe('cold history recovery view', () => {
maxMessages: 10,
})
if (!history.ok) throw new Error('history failed')
expect(history.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
expect(history.value.records.map(record => record.event)).toMatchInlineSnapshot(`
[
{
"data": {
@@ -564,7 +564,8 @@ describe('subagent ownership fence', () => {
},
throughSeq: 3,
}, new AbortController().signal)
expect(history.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
expect(history.records.map(record => record.event.type))
.toEqual(events.map(event => event.type))
expect(ctx.agents.get(sessionId)).toBeUndefined()
const prompt = await remote.prompt(promptRequest({
@@ -4,10 +4,16 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ChunkRowEvent,
SessionFollowFrame,
SessionPage,
SessionWireEvent,
} from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
/** Append a production-shaped human prompt to the session surface. */
@@ -77,6 +83,24 @@ async function openFollow(
return { [Symbol.asyncIterator]: () => iterator }
}
/** Expand packed page records for assertions over the logical journal. */
function pageEvents(page: SessionPage): SessionWireEvent[] {
return page.records.flatMap(record => record.type === 'event'
? [record.event]
: decodeStorageRecord(chunkRow(record.event)).map(event => event as unknown as SessionWireEvent))
}
function chunkRow(event: ChunkRowEvent): ChunkRow {
switch (event.type) {
case 'chunkrow/text-chunks':
return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data }
case 'chunkrow/reasoning-chunks':
return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data }
case 'chunkrow/tool-call-chunks':
return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data }
}
}
describe('Session history raw journal', () => {
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
@@ -169,10 +193,10 @@ describe('Session history raw journal', () => {
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
expect(response.value.events).toEqual([
{ event: start },
{ event: call },
{ event: result },
expect(response.value.records).toEqual([
{ type: 'event', event: start },
{ type: 'event', event: call },
{ type: 'event', event: result },
])
})
@@ -210,7 +234,7 @@ describe('Session history raw journal', () => {
maxMessages: 2,
})
if (!response.ok) throw new Error('unreachable')
const page = response.value.events.map(entry => entry.event)
const page = pageEvents(response.value)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
@@ -230,10 +254,10 @@ describe('Session history raw journal', () => {
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
chunk: { type: 'text-delta', index: 0, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
@@ -257,13 +281,71 @@ describe('Session history raw journal', () => {
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq])
expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1)
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('encodes reasoning and tool-call runs as aligned chunk events', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const reasoning = [0, 1, 2].map(index => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` },
}))
const callId = CallId('packed-call')
const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'tool-call-delta', index: 1, id: callId, argumentsDelta: `a${String(index)}` },
}))
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.records).toEqual([
{
type: 'chunks',
event: {
type: 'chunkrow/reasoning-chunks',
seq: reasoning[0]?.seq,
time: reasoning[0]?.time,
data: {
turn: 1,
step: 1,
index: 0,
dt: reasoning.slice(1).map((event, index) => event.time - (reasoning[index]?.time ?? 0)),
texts: ['r0', 'r1', 'r2'],
},
},
},
{
type: 'chunks',
event: {
type: 'chunkrow/tool-call-chunks',
seq: toolCall[0]?.seq,
time: toolCall[0]?.time,
data: {
turn: 1,
step: 1,
index: 1,
id: callId,
dt: toolCall.slice(1).map((event, index) => event.time - (toolCall[index]?.time ?? 0)),
args: ['a0', 'a1', 'a2'],
},
},
},
])
await ctx.fiber.dispose()
})
it('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
@@ -273,11 +355,17 @@ describe('Session history raw journal', () => {
const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'turn/start' } },
})
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'tool/call' } },
})
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'turn/end' } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
@@ -148,11 +148,12 @@ describe('session.history projections block', () => {
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
const snapshot = await opening(remote(ctx), session.id)
const { events, projections } = snapshot
const { records, projections } = snapshot
expect(projections.asOfSeq).toBe(session.seq - 1)
expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
// asOfSeq IS the window tail: the last served event carries it.
expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq)
const last = records.at(-1)
expect(last?.event.seq).toBe(projections.asOfSeq)
})
it('returns a complete current replacement cut on each follow generation', async () => {
@@ -162,7 +163,7 @@ describe('session.history projections block', () => {
const snapshot = await opening(remote(ctx), session.id)
expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1])
expect(snapshot.projections.asOfSeq).toBe(1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm1' } }),
@@ -175,7 +176,7 @@ describe('session.history projections block', () => {
const snapshot = await opening(remote(ctx), session.id)
expect(snapshot.events).toEqual([])
expect(snapshot.records).toEqual([])
expect(snapshot.projections.asOfSeq).toBe(-1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': null }),
@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
@@ -41,8 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
// History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok(historyValue(events, hasMore)))
}
describe('Session open', () => {
@@ -106,7 +105,7 @@ describe('Session open', () => {
follow(api, ev.user(16, '插进来的')),
]
gate.resolve(ok({
events: entries(page) as never[],
records: entries(page) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -227,7 +226,7 @@ describe('paging', () => {
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
records: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -571,7 +570,7 @@ describe('remaining branches', () => {
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
records: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // success, but its generation is gone
@@ -590,7 +589,7 @@ describe('remaining branches', () => {
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
records: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // repair result: stale, dropped
@@ -617,25 +616,25 @@ describe('remaining branches', () => {
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
api.onHistory = () => Promise.resolve(ok({
events: [
records: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: historyCall },
{ event: historyResult },
{ type: 'event', event: historyCall },
{ type: 'event', event: historyResult },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(windowEntries(session).slice(-2)).toEqual([
{ event: historyCall },
{ event: historyResult },
{ type: 'event', event: historyCall },
{ type: 'event', event: historyResult },
])
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
await follow(api, liveCall)
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall })
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
await follow(api, liveResult)
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult })
})
})
@@ -669,7 +668,7 @@ describe('resync', () => {
])
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
replacement.resolve(ok({
events: entries(plainTurn(10, 2, '终', '页')) as never[],
records: entries(plainTurn(10, 2, '终', '页')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -274,7 +274,7 @@ describe('Agent scope disposal lifecycle', () => {
createdAt: 0,
},
cursor: -1,
events: [],
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
@@ -343,7 +343,7 @@ describe('Agent scope disposal lifecycle', () => {
type: 'snapshot',
header: { version: 0, id: sessionId, createdAt: 0 },
cursor: -1,
events: [],
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
@@ -19,6 +19,7 @@ import type {
SessionEventEntry,
SessionFollowFrame,
SessionFollowRequest,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
} from '../src/types.ts'
@@ -36,16 +37,28 @@ const AVAILABLE_CONNECTION = {
}
function entry(seq: number): SessionEventEntry {
return { event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
}
function page(events: readonly SessionEventEntry[], hasMore = false): SessionPage {
return { events, hasMore }
function chunks(seq0: number): SessionHistoryRecord {
return {
type: 'chunks',
event: {
type: 'chunkrow/text-chunks',
seq: seq0,
time: seq0,
data: { turn: 1, step: 1, index: 0, texts: ['a', 'b', 'c'], dt: [1, 1] },
},
}
}
function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage {
return { records, hasMore }
}
function snapshot(
cursor: number,
events: readonly SessionEventEntry[],
records: readonly SessionHistoryRecord[],
hasMore = false,
): SessionFollowFrame {
return {
@@ -56,7 +69,7 @@ function snapshot(
createdAt: 0,
},
cursor,
events,
records,
hasMore,
projections: { asOfSeq: cursor, values: {} },
}
@@ -123,13 +136,60 @@ class ScriptedSessionRemote implements SessionTransportRemote {
}
describe('Session Client stream adapters', () => {
it('validates a packed logical range before publishing one compact Client entry', async () => {
const row = chunks(1)
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(4, [entry(0), row, entry(4)]), entry(5)], hold: true }],
[],
)
const changes: SessionJournalChange[] = []
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
failed: vi.fn(),
})
await stream.open({})
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
expect(changes[0]).toMatchObject({
type: 'replace',
entries: [
entry(0),
row,
entry(4),
],
})
expect(changes[0]?.type === 'replace' ? changes[0].entries[1] : undefined).toBe(row)
expect(changes[1]).toEqual({ type: 'append', entry: entry(5) })
await stream.dispose()
})
it('rejects a packed record emitted by the live follow path', async () => {
const failed = vi.fn()
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(-1, []), chunks(0) as SessionFollowFrame], hold: true }],
[],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
failed,
})
await stream.open({})
await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
expect(failed.mock.calls[0]?.[0]).toMatchObject({
message: 'session live stream emitted a packed history record',
})
await stream.dispose()
})
it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => {
const remote = new ScriptedSessionRemote(
[{
frames: [
snapshot(3, [entry(2), entry(3)], true),
{ type: 'event', ...entry(3) },
{ type: 'event', ...entry(4) },
entry(3),
entry(4),
],
hold: true,
}],
@@ -165,7 +225,7 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[
{
frames: [snapshot(1, [entry(0), entry(1)]), { type: 'event', ...entry(2) }],
frames: [snapshot(1, [entry(0), entry(1)]), entry(2)],
terminal: lost,
},
{ frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
@@ -221,7 +281,7 @@ describe('Session Client stream adapters', () => {
it('repairs a live gap without adding an absent message limit', async () => {
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }],
[{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }],
[{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
)
const changes: SessionJournalChange[] = []
@@ -87,7 +87,7 @@ describe('SessionHistoryController', () => {
{ address: { kind: 'session', sessionId: session.id }, throughSeq: 1 },
new AbortController().signal,
)
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
abort.abort()
expect(await iterator.next()).toMatchObject({ done: true })
@@ -137,7 +137,11 @@ describe('SessionHistoryController', () => {
value: {
type: 'snapshot',
cursor: 2,
events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { event: { seq: 2 } }],
records: [
{ type: 'event', event: { seq: 0 } },
{ type: 'event', event: { seq: 1 } },
{ type: 'event', event: { seq: 2 } },
],
},
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
@@ -205,7 +209,12 @@ describe('SessionHistoryController', () => {
await expect(opening).resolves.toMatchObject({
done: false,
value: {
type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }],
type: 'snapshot',
cursor: 1,
records: [
{ type: 'event', event: { seq: 0 } },
{ type: 'event', event: { seq: 1 } },
],
},
})
expect(attached.id).toBe(sessionId)
@@ -307,7 +316,7 @@ describe('SessionHistoryController', () => {
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
await expect(transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [], hasMore: false })
}, signal())).resolves.toMatchObject({ records: [], hasMore: false })
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
@@ -383,7 +392,9 @@ describe('SessionHistoryController', () => {
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: 0,
}, signal)).resolves.toMatchObject({ events: [{ event: { type: 'subagent/descriptor' } }] })
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
})
await expect(transport.page({
address: {
kind: 'subagent',
@@ -511,7 +522,9 @@ describe('SessionHistoryController', () => {
await expect(ordinaryBench.transport.page({
address: { kind: 'session', sessionId: ordinaryId },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ events: [{ event: { seq: 0 } }] })
}, signal())).resolves.toMatchObject({
records: [{ type: 'event', event: { seq: 0 } }],
})
const parentSessionId = SessionId('cold-parent')
const childSessionId = SessionId('cold-child')
@@ -624,12 +637,13 @@ describe('SessionHistoryController', () => {
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([3, 4, 5, replacement.seq])
expect(page.records.map(entry => entry.event.seq))
.toEqual([3, 4, 5, replacement.seq])
expect(page.hasMore).toBe(true)
const before = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1,
}, signal())
expect(before.events.map(entry => entry.event.seq)).toEqual([2])
expect(before.records.map(entry => entry.event.seq)).toEqual([2])
})
it('keeps cited source events in the page that owns their appended message', async () => {
@@ -643,7 +657,7 @@ describe('SessionHistoryController', () => {
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.hasMore).toBe(false)
})