Merge pinned master into status bar projection
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
|
||||
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
|
||||
README.md: a116a5e4ad3070f20e6d90490f2507c1e2369c37
|
||||
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 scope(host 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 scope(host 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 报告的目标,同时不替换未变化的选择子结构。
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"' }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user