Merge pinned master into status bar projection

This commit is contained in:
Hypatia May
2026-07-31 09:28:52 +08:00
831 changed files with 23503 additions and 4750 deletions

View File

@@ -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/client/runtime/README.md
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
README.md: a116a5e4ad3070f20e6d90490f2507c1e2369c37
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists
@@ -28,6 +28,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表
@@ -28,6 +28,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
## 会话模型选择
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。

View File

@@ -29,6 +29,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).

View File

@@ -122,6 +122,28 @@ declare module 'cordis' {
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -170,8 +192,13 @@ export function apply(ctx: Context): void {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches) subscribe on ctx.
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
// session routing); consumers (command directory caches, the settings
// and model surfaces) subscribe on ctx.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {

View File

@@ -1,12 +1,14 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
}
}
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
void this.loadForConsumers()
}
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
if (generation === this.generation) this.publishDirtyNow()
}
}
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry]
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -289,6 +289,40 @@ export class SessionManager {
}
}
/**
* Contract session.fork; on success merge the child into summaries
* immediately (same synchronous-addressability guarantee as create). The
* child carries the source's history, so it is never blank; lineage rides
* parentSessionId so the list nests it under its source. A child published
* before Workspace attachment fails is also reconciled into the list.
* @param opts - source session and the optional seq anchoring the cut.
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const { result } = await this.api.sessions.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
})
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
} catch (error) {
return transportError(error)
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the

View File

@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**

View File

@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
}
}
/** Structured session-fork failure. */
export class SessionForkError extends Error {
override readonly name = 'SessionForkError'
/**
* @param rpcError - Host business or folded transport error.
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: RpcError,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
@@ -121,6 +137,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
return id
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - source session's durable title.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
}
const fullWidth = /^(.*?)(\d+)$/u.exec(title)
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
return `${fullWidth[1]}${BigInt(fullWidth[2]) + 1n}`
}
return `${title} (1)`
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
@@ -317,6 +351,42 @@ export class SessionsService implements ISessions {
return result.value.sessionId
}
/**
* Fork a session from a completed-turn prefix of the source (same
* synchronous-addressability guarantee as {@link SessionsService.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
*/
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
const child = this.binding(childId)?.session
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
const renamed = await child.rename(increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).

View File

@@ -62,8 +62,9 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -71,7 +72,7 @@ export class FakeApiClient implements IApiClient {
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
id: 'deepseek-official',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
@@ -181,6 +183,24 @@ export class FakeApiClient implements IApiClient {
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -277,6 +277,23 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a fork child published before workspace attachment fails', async () => {
const api = new FakeApiClient()
api.onFork = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
})])
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
@@ -361,7 +378,7 @@ describe('connected generation', () => {
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)

View File

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -78,7 +78,7 @@ describe('open', () => {
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
@@ -135,7 +135,7 @@ describe('live event path', () => {
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')
@@ -255,7 +255,7 @@ describe('paging', () => {
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
@@ -571,7 +571,7 @@ describe('remaining branches', () => {
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
@@ -594,7 +594,7 @@ describe('remaining branches', () => {
secondPull.resolve(ok({
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
}))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
@@ -612,7 +612,7 @@ describe('remaining branches', () => {
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
})) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
@@ -657,7 +657,7 @@ describe('remaining branches', () => {
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({

View File

@@ -10,7 +10,7 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -399,6 +399,69 @@ describe('create', () => {
})
})
describe('fork', () => {
it.each([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
['计划1', '计划2'],
['计划 9', '计划 10'],
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
})
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = (payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
}
await expect(b.svc.fork({
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
})).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
displayTitle: childTitle,
parentId: 'source',
})
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
expect(b.api.callsOf('session.rename')).toEqual([])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
expect(b.api.callsOf('session.rename')).toEqual([])
})
it('rejects when child rename fails while keeping the published child addressable', async () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
})
await feedList(b, [{ id: 'source' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
}))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()

View File

@@ -44,6 +44,22 @@ describe('wire event bridge', () => {
expect(changed).toBe(1)
})
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
bench.ctx.on('models/changed', () => seen.push(['models']))
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
expect(seen).toEqual([
['settings', 'llm-pi-ai'],
['credentials', 'OPENAI_API_KEY'],
['models'],
])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0