mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
perf(history): retain packed records in client
This commit is contained in:
@@ -60,7 +60,10 @@ interface WorkspaceBaseline {
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
records: Array<{ event: HistoryEvent } | { chunks: unknown }>
|
||||
records: Array<
|
||||
| { type: 'event'; event: HistoryEvent }
|
||||
| { type: 'chunks'; event: HistoryChunkEvent }
|
||||
>
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
@@ -69,6 +72,11 @@ interface HistoryEvent {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
interface HistoryChunkEvent extends HistoryEvent {
|
||||
seq: number
|
||||
time: number
|
||||
}
|
||||
|
||||
interface ProcessObservation {
|
||||
readonly ready: Promise<string>
|
||||
readonly text: () => string
|
||||
@@ -308,7 +316,14 @@ function assistantText(page: HistoryPage): string {
|
||||
|
||||
/** Expand lossless history records for assertions over the public event stream. */
|
||||
function historyEvents(page: HistoryPage): HistoryEvent[] {
|
||||
return page.records.flatMap(record => 'event' in record ? [record.event] : decodeStorageRecord(record.chunks))
|
||||
return page.records.flatMap(record => record.type === 'event'
|
||||
? [record.event]
|
||||
: decodeStorageRecord({
|
||||
type: record.event.type.replace(/^chunkrow\//u, ''),
|
||||
seq0: record.event.seq,
|
||||
time0: record.event.time,
|
||||
data: record.event.data,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
|
||||
|
||||
@@ -180,7 +180,10 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise<number
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
records: ({ event: HistoryEvent } | { chunks: unknown })[]
|
||||
records: (
|
||||
| { type: 'event'; event: HistoryEvent }
|
||||
| { type: 'chunks'; event: HistoryEvent }
|
||||
)[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
@@ -194,7 +197,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function providerTitle(page: HistoryPage): string | undefined {
|
||||
const events = page.records.flatMap(record => 'event' in record ? [record.event] : [])
|
||||
const events = page.records.flatMap(record => record.type === 'event' ? [record.event] : [])
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index] as HistoryEvent
|
||||
if (event.type !== 'session/title' || !isRecord(event.data)) continue
|
||||
@@ -208,7 +211,7 @@ function providerTitle(page: HistoryPage): string | undefined {
|
||||
|
||||
function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
|
||||
return page.records.some((record) => {
|
||||
if (!('event' in record)) return false
|
||||
if (record.type !== 'event') return false
|
||||
const { event } = record
|
||||
if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) return false
|
||||
const content = event.data.message.content
|
||||
@@ -564,9 +567,11 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
return hasAssistantMarker(page, recoveredMarker)
|
||||
}, { timeout: 20_000 }).toBe(true)
|
||||
if (page === undefined) throw new Error('retry history was not observed')
|
||||
const retry = page.records.find(record => 'event' in record && record.event.type === 'llm/retry')
|
||||
const retry = page.records.find(record => (
|
||||
record.type === 'event' && record.event.type === 'llm/retry'
|
||||
))
|
||||
expect(mainAttempts).toBe(2)
|
||||
expect(retry !== undefined && 'event' in retry ? retry.event.data : undefined).toMatchObject({
|
||||
expect(retry?.type === 'event' ? retry.event.data : undefined).toMatchObject({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: ce914f90821adc09c59dca15f202831a51655565
|
||||
event-producer-consumer.zh.md: ca375c98b6c0552f255e5d2604c9ad0c1d968aad
|
||||
event-producer-consumer.md: b38d919b0586a116e163d8be3eb2c33fcfc6d67d
|
||||
event-producer-consumer.zh.md: fd36b3ebf40d5467f4b0849d972a7925838dee65
|
||||
|
||||
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:491`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:471`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:498`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:477`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:484`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -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: 24df6e1e367eb76e942087065cf9a29a34e94127
|
||||
README.zh.md: 710912be75195567b557c7dc103cabb7eed1e80f
|
||||
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 carry `records`: ordinary records contain a raw `SessionWireEvent`, while consecutive same-block `assistant/chunk` deltas use the Session package's lossless packed-row encoding. `SessionEventStream` expands packed rows member-for-member before publishing a page to the Client Session object layer, so replay still observes every original event and sequence number. Follow frames remain raw individual events. 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 状态。
|
||||
|
||||
历史页携带 `records`:普通记录包含原始 `SessionWireEvent`,连续且属于同一 block 的 `assistant/chunk` delta 使用 Session 包的无损打包行编码。`SessionEventStream` 会在向 Client Session 对象层发布页面前逐成员展开打包行,因此回放仍能观察到每个原始事件和序号。Follow frame 继续携带单个原始事件。工具参数、结果内容、失败信息和 `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,
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
/** Lossless client decoding for packed Assistant delta runs in history responses. */
|
||||
/** Client range access and type narrowing for aligned Session history records. */
|
||||
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type {
|
||||
SessionEventEntry,
|
||||
SessionHistoryRecord,
|
||||
SessionWireEvent,
|
||||
} from '../../types.ts'
|
||||
import type { SessionEventLikeEntry } from '../contract/events.ts'
|
||||
|
||||
/**
|
||||
* Convert history wire records into exact event inputs for Conversation Definitions.
|
||||
* @param records - validated lossless history transport records.
|
||||
* @returns Ordinary entries unchanged and packed runs expanded member-for-member.
|
||||
* 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[]): SessionEventEntry[] {
|
||||
return records.flatMap(record => 'event' in record
|
||||
? [record]
|
||||
: decodeStorageRecord(record.chunks).map(event => ({
|
||||
event: event as unknown as SessionWireEvent,
|
||||
})))
|
||||
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,12 +14,17 @@ import {
|
||||
import type {
|
||||
SessionAddress,
|
||||
SessionControlFrame,
|
||||
SessionEventEntry,
|
||||
SessionHistoryRecord,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
SessionProjectionBaseline,
|
||||
} from '../types.ts'
|
||||
import { historyEntries } from './sessions/history-records.ts'
|
||||
import {
|
||||
historyEntries,
|
||||
historyRecordFirstSeq,
|
||||
historyRecordLastSeq,
|
||||
} from './sessions/history-records.ts'
|
||||
import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts'
|
||||
|
||||
export {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
@@ -38,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>
|
||||
@@ -101,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
|
||||
> {
|
||||
@@ -118,12 +149,13 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
super(remote, {
|
||||
name: 'session event stream',
|
||||
emptyCursor: -1,
|
||||
entries: page => historyEntries(page.records),
|
||||
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 }),
|
||||
@@ -135,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 }),
|
||||
@@ -152,8 +184,7 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
}
|
||||
continue
|
||||
}
|
||||
const { type: _type, ...entry } = frame
|
||||
yield { type: 'entry', entry }
|
||||
yield { type: 'entry', entry: frame }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
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,
|
||||
@@ -157,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)
|
||||
@@ -315,14 +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)
|
||||
? { chunks: record }
|
||||
? chunkEntryFor(record)
|
||||
: entryFor(record))
|
||||
}
|
||||
|
||||
@@ -362,12 +362,24 @@ 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 chunks: ChunkRow
|
||||
readonly type: 'chunks'
|
||||
readonly event: ChunkRowEvent
|
||||
}
|
||||
|
||||
/** One history-page record: a raw event or a packed Assistant delta run. */
|
||||
@@ -415,7 +427,7 @@ export type SessionFollowFrame =
|
||||
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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ 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[]): SessionEventEntry[] {
|
||||
return events.map(event => ({ event: event as unknown as SessionWireEvent }))
|
||||
return events.map(event => ({ type: 'event', event: event as unknown as SessionWireEvent }))
|
||||
}
|
||||
|
||||
/** Build one view-less history response value. */
|
||||
|
||||
@@ -28,7 +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 { historyEntries } from '../src/client/sessions/history-records.ts'
|
||||
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
|
||||
|
||||
const AVAILABLE_STREAM_CONNECTION = {
|
||||
hostDescription: {
|
||||
@@ -440,8 +440,8 @@ export class FakeApiClient implements IApiClient {
|
||||
ok: true,
|
||||
value: {
|
||||
...result.value,
|
||||
records: historyEntries(result.value.records)
|
||||
.filter(entry => entry.event.seq <= request.throughSeq),
|
||||
records: result.value.records
|
||||
.filter(record => historyRecordLastSeq(record) <= request.throughSeq),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -469,8 +469,8 @@ export class FakeApiClient implements IApiClient {
|
||||
)
|
||||
}
|
||||
const page = response.result.value
|
||||
const entries = historyEntries(page.records)
|
||||
const cursor = this.followCursor ?? entries.at(-1)?.event.seq ?? -1
|
||||
const tail = page.records.at(-1)
|
||||
const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
|
||||
yield {
|
||||
type: 'snapshot',
|
||||
header: {
|
||||
@@ -482,7 +482,7 @@ export class FakeApiClient implements IApiClient {
|
||||
: {}),
|
||||
},
|
||||
cursor,
|
||||
records: entries.filter(entry => entry.event.seq <= cursor),
|
||||
records: page.records.filter(record => historyRecordLastSeq(record) <= cursor),
|
||||
hasMore: page.hasMore,
|
||||
projections: page.projections ?? { asOfSeq: cursor, values: {} },
|
||||
}
|
||||
|
||||
@@ -1,50 +1,60 @@
|
||||
/** Packed history records decode to the exact Session event stream. */
|
||||
/** 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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionHistoryRecord } from '../src/types.ts'
|
||||
import { historyEntries } from '../src/client/sessions/history-records.ts'
|
||||
import {
|
||||
historyEntries,
|
||||
historyRecordFirstSeq,
|
||||
historyRecordLastSeq,
|
||||
} from '../src/client/sessions/history-records.ts'
|
||||
|
||||
describe('historyEntries', () => {
|
||||
it('keeps ordinary entries and expands every packed text member with its exact boundary', () => {
|
||||
const ordinary = {
|
||||
event: { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
} as SessionHistoryRecord
|
||||
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 = {
|
||||
chunks: {
|
||||
type: 'text-chunks',
|
||||
seq0: 1,
|
||||
time0: 2,
|
||||
data: { turn: 1, step: 1, index: 0, dt: [1, 1, 1], texts: ['a', 'b', 'c', 'd'] },
|
||||
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 entries = historyEntries([ordinary, packed])
|
||||
const [entry] = historyEntries([packed])
|
||||
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
|
||||
const { event } = entry
|
||||
|
||||
expect(entries).toHaveLength(5)
|
||||
expect(entries[0]).toBe(ordinary)
|
||||
expect(entries.slice(1).map((entry) => {
|
||||
const event = entry.event as unknown as SessionEvent<'assistant/chunk'>
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
chunk: event.data.chunk,
|
||||
}
|
||||
})).toEqual([
|
||||
{ seq: 1, time: 2, chunk: { type: 'text-delta', index: 0, text: 'a' } },
|
||||
{ seq: 2, time: 3, chunk: { type: 'text-delta', index: 0, text: 'b' } },
|
||||
{ seq: 3, time: 4, chunk: { type: 'text-delta', index: 0, text: 'c' } },
|
||||
{ seq: 4, time: 5, chunk: { type: 'text-delta', index: 0, text: 'd' } },
|
||||
])
|
||||
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 every tool-call fragment and optional-name presence', () => {
|
||||
it('preserves a packed tool-call row and optional-name absence', () => {
|
||||
const packed: SessionHistoryRecord = {
|
||||
chunks: {
|
||||
type: 'tool-call-chunks',
|
||||
seq0: 20,
|
||||
time0: 200,
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/tool-call-chunks',
|
||||
seq: 20,
|
||||
time: 200,
|
||||
data: {
|
||||
turn: 2,
|
||||
step: 4,
|
||||
@@ -56,14 +66,13 @@ describe('historyEntries', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const events = historyEntries([packed])
|
||||
.map(entry => entry.event as unknown as SessionEvent<'assistant/chunk'>)
|
||||
const [entry] = historyEntries([packed])
|
||||
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
|
||||
const { event } = entry
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{ seq: 20, time: 200, data: { chunk: { argumentsDelta: '' } } },
|
||||
{ seq: 21, time: 202, data: { chunk: { argumentsDelta: '{"x":' } } },
|
||||
{ seq: 22, time: 205, data: { chunk: { argumentsDelta: '1}' } } },
|
||||
])
|
||||
expect(events.every(event => !Object.hasOwn(event.data.chunk, 'name'))).toBe(true)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -390,7 +390,7 @@ describe('cold history recovery view', () => {
|
||||
maxMessages: 10,
|
||||
})
|
||||
if (!history.ok) throw new Error('history failed')
|
||||
expect(history.value.records.map(record => 'event' in record ? record.event : record.chunks)).toMatchInlineSnapshot(`
|
||||
expect(history.value.records.map(record => record.event)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
@@ -564,7 +564,7 @@ describe('subagent ownership fence', () => {
|
||||
},
|
||||
throughSeq: 3,
|
||||
}, new AbortController().signal)
|
||||
expect(history.records.map(record => 'event' in record ? record.event.type : record.chunks.type))
|
||||
expect(history.records.map(record => record.event.type))
|
||||
.toEqual(events.map(event => event.type))
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ 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 } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
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 {
|
||||
ChunkRowEvent,
|
||||
SessionFollowFrame,
|
||||
SessionPage,
|
||||
SessionWireEvent,
|
||||
@@ -84,9 +85,20 @@ async function openFollow(
|
||||
|
||||
/** Expand packed page records for assertions over the logical journal. */
|
||||
function pageEvents(page: SessionPage): SessionWireEvent[] {
|
||||
return page.records.flatMap(record => 'event' in record
|
||||
return page.records.flatMap(record => record.type === 'event'
|
||||
? [record.event]
|
||||
: decodeStorageRecord(record.chunks).map(event => event as unknown as SessionWireEvent))
|
||||
: 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', () => {
|
||||
@@ -182,9 +194,9 @@ describe('Session history raw journal', () => {
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
expect(response.value.records).toEqual([
|
||||
{ event: start },
|
||||
{ event: call },
|
||||
{ event: result },
|
||||
{ type: 'event', event: start },
|
||||
{ type: 'event', event: call },
|
||||
{ type: 'event', event: result },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -270,7 +282,7 @@ describe('Session history raw journal', () => {
|
||||
})
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq])
|
||||
expect(response.value.records.filter(record => 'chunks' in record)).toHaveLength(1)
|
||||
expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1)
|
||||
expect(response.value.hasMore).toBe(true)
|
||||
} finally {
|
||||
min.mockRestore()
|
||||
@@ -286,11 +298,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')
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('session.history projections block', () => {
|
||||
expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
|
||||
// asOfSeq IS the window tail: the last served event carries it.
|
||||
const last = records.at(-1)
|
||||
expect(last !== undefined && 'event' in last ? last.event.seq : last?.chunks.seq0).toBe(projections.asOfSeq)
|
||||
expect(last?.event.seq).toBe(projections.asOfSeq)
|
||||
})
|
||||
|
||||
it('returns a complete current replacement cut on each follow generation', async () => {
|
||||
@@ -163,7 +163,7 @@ describe('session.history projections block', () => {
|
||||
|
||||
const snapshot = await opening(remote(ctx), session.id)
|
||||
|
||||
expect(snapshot.records.map(record => 'event' in record ? record.event.seq : record.chunks.seq0)).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' } }),
|
||||
|
||||
@@ -618,23 +618,23 @@ describe('remaining branches', () => {
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { records: 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,
|
||||
records: events,
|
||||
records,
|
||||
hasMore,
|
||||
projections: { asOfSeq: cursor, values: {} },
|
||||
}
|
||||
@@ -123,13 +136,41 @@ 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('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 +206,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 +262,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.records.map(entry => 'event' in entry ? entry.event.seq : entry.chunks.seq0)).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,
|
||||
records: [{ 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' } })
|
||||
@@ -200,7 +204,12 @@ describe('SessionHistoryController', () => {
|
||||
await expect(opening).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot', cursor: 1, records: [{ 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)
|
||||
@@ -377,7 +386,9 @@ describe('SessionHistoryController', () => {
|
||||
await expect(transport.page({
|
||||
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
|
||||
throughSeq: 0,
|
||||
}, signal)).resolves.toMatchObject({ records: [{ event: { type: 'subagent/descriptor' } }] })
|
||||
}, signal)).resolves.toMatchObject({
|
||||
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
|
||||
})
|
||||
await expect(transport.page({
|
||||
address: {
|
||||
kind: 'subagent',
|
||||
@@ -505,7 +516,9 @@ describe('SessionHistoryController', () => {
|
||||
await expect(ordinaryBench.transport.page({
|
||||
address: { kind: 'session', sessionId: ordinaryId },
|
||||
throughSeq: 0,
|
||||
}, signal())).resolves.toMatchObject({ records: [{ event: { seq: 0 } }] })
|
||||
}, signal())).resolves.toMatchObject({
|
||||
records: [{ type: 'event', event: { seq: 0 } }],
|
||||
})
|
||||
|
||||
const parentSessionId = SessionId('cold-parent')
|
||||
const childSessionId = SessionId('cold-child')
|
||||
@@ -618,13 +631,13 @@ describe('SessionHistoryController', () => {
|
||||
const page = await transport.page({
|
||||
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2,
|
||||
}, signal())
|
||||
expect(page.records.map(entry => 'event' in entry ? entry.event.seq : entry.chunks.seq0))
|
||||
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.records.map(entry => 'event' in entry ? entry.event.seq : entry.chunks.seq0)).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 () => {
|
||||
@@ -638,7 +651,7 @@ describe('SessionHistoryController', () => {
|
||||
const page = await transport.page({
|
||||
address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1,
|
||||
}, signal())
|
||||
expect(page.records.map(entry => 'event' in entry ? entry.event.seq : entry.chunks.seq0)).toEqual([0, 1])
|
||||
expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
|
||||
expect(page.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,11 +75,22 @@ interface FixtureProjectionsBlock {
|
||||
}
|
||||
|
||||
interface FixtureHistoryEntry {
|
||||
readonly type: 'event'
|
||||
readonly event: SessionEvent
|
||||
}
|
||||
|
||||
type FixtureChunkRowEvent = {
|
||||
[Kind in ChunkRow['type']]: {
|
||||
readonly type: `chunkrow/${Kind}`
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: Extract<ChunkRow, { readonly type: Kind }>['data']
|
||||
}
|
||||
}[ChunkRow['type']]
|
||||
|
||||
interface FixtureHistoryChunkRun {
|
||||
readonly chunks: ChunkRow
|
||||
readonly type: 'chunks'
|
||||
readonly event: FixtureChunkRowEvent
|
||||
}
|
||||
|
||||
type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun
|
||||
@@ -114,7 +125,7 @@ type FixtureFollowFrame =
|
||||
readonly hasMore: boolean
|
||||
readonly projections: FixtureProjectionsBlock
|
||||
}
|
||||
| ({ readonly type: 'event' } & FixtureHistoryEntry)
|
||||
| FixtureHistoryEntry
|
||||
|
||||
type FixtureFollowEventFrame = Extract<FixtureFollowFrame, { type: 'event' }>
|
||||
|
||||
@@ -1414,9 +1425,26 @@ function pageOf(
|
||||
break
|
||||
}
|
||||
}
|
||||
const records = packChunkRuns(log.slice(start, end)).map((record): FixtureHistoryRecord => (
|
||||
isChunkRow(record) ? { chunks: record } : { event: record }
|
||||
))
|
||||
const records = packChunkRuns(log.slice(start, end)).map((record): FixtureHistoryRecord => {
|
||||
if (!isChunkRow(record)) return { type: 'event', event: record }
|
||||
switch (record.type) {
|
||||
case 'text-chunks':
|
||||
return {
|
||||
type: 'chunks',
|
||||
event: { type: 'chunkrow/text-chunks', seq: record.seq0, time: record.time0, data: record.data },
|
||||
}
|
||||
case 'reasoning-chunks':
|
||||
return {
|
||||
type: 'chunks',
|
||||
event: { type: 'chunkrow/reasoning-chunks', seq: record.seq0, time: record.time0, data: record.data },
|
||||
}
|
||||
case 'tool-call-chunks':
|
||||
return {
|
||||
type: 'chunks',
|
||||
event: { type: 'chunkrow/tool-call-chunks', seq: record.seq0, time: record.time0, data: record.data },
|
||||
}
|
||||
}
|
||||
})
|
||||
return { records, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
@@ -1884,7 +1912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
for (const conn of remoteEventConns.values()) conn.push(frame)
|
||||
}
|
||||
const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => {
|
||||
for (const conn of followConns.get(sessionId) ?? []) conn.push({ type: 'event', ...entry })
|
||||
for (const conn of followConns.get(sessionId) ?? []) conn.push(entry)
|
||||
}
|
||||
|
||||
/** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */
|
||||
@@ -1942,7 +1970,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const log = logOf(id)
|
||||
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
|
||||
log.push(event)
|
||||
emitFollow(id, { event })
|
||||
emitFollow(id, { type: 'event', event })
|
||||
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitControl(frame)
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'user') {
|
||||
|
||||
@@ -37,11 +37,22 @@ interface FixtureSessionSummary {
|
||||
}
|
||||
|
||||
interface FixtureHistoryEntry {
|
||||
readonly type: 'event'
|
||||
readonly event: SessionEvent
|
||||
}
|
||||
|
||||
type FixtureChunkRowEvent = {
|
||||
[Kind in ChunkRow['type']]: {
|
||||
readonly type: `chunkrow/${Kind}`
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: Extract<ChunkRow, { readonly type: Kind }>['data']
|
||||
}
|
||||
}[ChunkRow['type']]
|
||||
|
||||
interface FixtureHistoryChunkRun {
|
||||
readonly chunks: ChunkRow
|
||||
readonly type: 'chunks'
|
||||
readonly event: FixtureChunkRowEvent
|
||||
}
|
||||
|
||||
type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun
|
||||
@@ -52,9 +63,20 @@ interface FixturePage {
|
||||
}
|
||||
|
||||
function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[] {
|
||||
return records.flatMap(record => 'event' in record
|
||||
return records.flatMap(record => record.type === 'event'
|
||||
? [record.event]
|
||||
: decodeStorageRecord(record.chunks))
|
||||
: decodeStorageRecord(chunkRow(record.event)))
|
||||
}
|
||||
|
||||
function chunkRow(event: FixtureChunkRowEvent): 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 }
|
||||
}
|
||||
}
|
||||
|
||||
type FixtureFollowFrame =
|
||||
@@ -68,7 +90,7 @@ type FixtureFollowFrame =
|
||||
readonly values: Readonly<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
| ({ readonly type: 'event' } & FixtureHistoryEntry)
|
||||
| FixtureHistoryEntry
|
||||
|
||||
type FixtureControlFrame =
|
||||
| {
|
||||
|
||||
@@ -3343,6 +3343,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'Branded',
|
||||
declaration: 'export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n};',
|
||||
},
|
||||
{
|
||||
name: 'ChunkRowEvent',
|
||||
declaration: 'export type ChunkRowEvent = {\n [Kind in ChunkRow[\'type\']]: {\n readonly type: `chunkrow/${Kind}`;\n readonly seq: number;\n readonly time: number;\n readonly data: Extract<ChunkRow, {\n readonly type: Kind;\n }>[\'data\'];\n };\n}[ChunkRow[\'type\']];',
|
||||
},
|
||||
{
|
||||
name: 'ClientArtifactBaseline',
|
||||
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}',
|
||||
@@ -4445,7 +4449,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionChunkRun',
|
||||
declaration: 'export interface SessionChunkRun {\n readonly chunks: ChunkRow;\n}',
|
||||
declaration: 'export interface SessionChunkRun {\n readonly type: \'chunks\';\n readonly event: ChunkRowEvent;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionControlBaseline',
|
||||
@@ -4477,7 +4481,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventEntry',
|
||||
declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}',
|
||||
declaration: 'export interface SessionEventEntry {\n readonly type: \'event\';\n readonly event: SessionWireEvent;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
@@ -4541,7 +4545,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionFollowFrame',
|
||||
declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);',
|
||||
declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | SessionEventEntry;',
|
||||
},
|
||||
{
|
||||
name: 'SessionFollowRequest',
|
||||
|
||||
Reference in New Issue
Block a user