mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
Merge origin/master into schedule-web-catalog
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 的情况下检查 persistence;queue 变更和取消要求对应 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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user