feat(client): add definition-owned conversation groups
This commit is contained in:
1 parent
eaf0f68bb2
commit
c890269a19
20 files changed
+1043
-36
No files matched your search
@@ -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/subsystems/conversation.md
|
||||
conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098
|
||||
conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3
|
||||
conversation.md: 0478b546154bddffaa7003df77a995fdcbfa9c39
|
||||
conversation.zh.md: 386e25dcfbaa2f9137f723d7d6a3b03963a1e5a2
|
||||
@@ -16,6 +16,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se
|
||||
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. |
|
||||
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
|
||||
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
|
||||
| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. |
|
||||
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
|
||||
|
||||
Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence.
|
||||
@@ -26,6 +27,29 @@ Each Session keeps a monotonic set of active targets. Creating or reading a targ
|
||||
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity.
|
||||
|
||||
<a id="group-definitions"></a>
|
||||
## Group Definitions
|
||||
|
||||
The optional `ctx.uiConversation.groups.register(definition)` contribution uses the same effect-owned registry lifecycle as event and view registrations. It requires an existing View target and rejects duplicate target registrations without constructing a Builder. Each Session creates its own Group context only when that target activates. Registration replacement discards the old context and clears its observed groups during the existing registry rebuild.
|
||||
|
||||
The [group types](../../packages/client/ui-conversation/src/client/contract/groups.ts) define the complete protocol:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `ConversationGroupDefinition<Node, State, Data>` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. |
|
||||
| `ConversationGroupInput<Node>` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. |
|
||||
| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. |
|
||||
| `GroupSnapshot<Data>` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. |
|
||||
| `GroupUpdate<Data>` | Optional `entries` replaces the complete root sequence; omission preserves it. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. |
|
||||
| `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. |
|
||||
| `ConversationGroupedView<Data>` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. |
|
||||
|
||||
The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence.
|
||||
|
||||
The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference.
|
||||
|
||||
Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies.
|
||||
|
||||
## Replayable event families
|
||||
|
||||
Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context.
|
||||
|
||||
@@ -16,6 +16,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session
|
||||
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 |
|
||||
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
|
||||
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
|
||||
| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 |
|
||||
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
|
||||
|
||||
Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。
|
||||
@@ -26,6 +27,29 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
|
||||
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。
|
||||
|
||||
<a id="group-definitions"></a>
|
||||
## Group Definition
|
||||
|
||||
可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。
|
||||
|
||||
[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议:
|
||||
|
||||
| 类型 | 含义 |
|
||||
|---|---|
|
||||
| `ConversationGroupDefinition<Node, State, Data>` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 |
|
||||
| `ConversationGroupInput<Node>` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 |
|
||||
| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 |
|
||||
| `GroupSnapshot<Data>` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 |
|
||||
| `GroupUpdate<Data>` | 可选的 `entries` 替换完整根序列,省略则保留。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 |
|
||||
| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 |
|
||||
| `ConversationGroupedView<Data>` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 |
|
||||
|
||||
注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。
|
||||
|
||||
Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。
|
||||
|
||||
渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。
|
||||
|
||||
## 可回放 event family
|
||||
|
||||
编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
|
||||
|
||||
@@ -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/ui-conversation/README.md
|
||||
README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726
|
||||
README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab
|
||||
README.md: 496c60e29582573abe15253ad0efb46e5bd7125f
|
||||
README.zh.md: 71d2afefd9b22e0af21d2cd496d53cda9ac6a46c
|
||||
@@ -31,6 +31,8 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o
|
||||
|
||||
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
|
||||
|
||||
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
|
||||
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work.
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
|
||||
|
||||
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
|
||||
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupedView, ConversationGroupInput,
|
||||
} from './groups.ts'
|
||||
|
||||
/** Definition-local identity and lifecycle role extracted from one event. */
|
||||
export interface ConversationMatchResult {
|
||||
@@ -141,6 +144,14 @@ export interface ConversationViewSnapshotStore {
|
||||
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
|
||||
target: Target,
|
||||
): ConversationViewSnapshotMap[Target] | undefined
|
||||
/**
|
||||
* Read grouping for an activated target without activating another target.
|
||||
* @param target - registered View target.
|
||||
* @returns its grouped reader, when a Group Definition is active.
|
||||
*/
|
||||
grouped<Target extends string>(
|
||||
target: Target,
|
||||
): ConversationGroupedView<ConversationGroupData<Target>> | undefined
|
||||
}
|
||||
|
||||
/** Immutable public view of an assembled business Context. */
|
||||
@@ -261,6 +272,7 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
replace(input: {
|
||||
readonly nodes: readonly Node[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly changedTurns?: readonly number[]
|
||||
}): Snapshot
|
||||
/**
|
||||
* Apply only Nodes whose materialized values changed in this transaction.
|
||||
@@ -270,7 +282,12 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
apply(input: {
|
||||
readonly upserts: readonly Node[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly changedTurns?: readonly number[]
|
||||
}): Snapshot
|
||||
/** @returns the latest target-processed Node inputs; required only for a grouped target. */
|
||||
groupInput?(): ConversationGroupInput<Node>
|
||||
/** Publish local sources after all target snapshots and grouping results have been installed. */
|
||||
publish?(): void
|
||||
}
|
||||
|
||||
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Definition-owned grouping over a target's already materialized Nodes. */
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ConversationTimelineSnapshot, ConversationViewNode } from './conversation.ts'
|
||||
|
||||
/** Existing Node identity, without allocating another execution Node. */
|
||||
export type NodeKey = Branded<'ConversationNodeKey'>
|
||||
|
||||
/** Definition-local group identity within one Session and target. */
|
||||
export type GroupKey = Branded<'ConversationGroupKey'>
|
||||
|
||||
/** One whole Node or a renderer-owned part of that Node. */
|
||||
export interface NodeReference {
|
||||
readonly kind: 'node'
|
||||
readonly key: NodeKey
|
||||
readonly groupPart?: string
|
||||
}
|
||||
|
||||
/** Root-list reference to a separately observed group. */
|
||||
export interface GroupReference {
|
||||
readonly kind: 'group'
|
||||
readonly key: GroupKey
|
||||
}
|
||||
|
||||
/** The two supported root rendering positions. */
|
||||
export type RenderEntry = NodeReference | GroupReference
|
||||
|
||||
/** Immutable group data and its ordered, non-nested Node references. */
|
||||
export interface GroupSnapshot<Data> {
|
||||
readonly key: GroupKey
|
||||
readonly data: Data
|
||||
readonly members: readonly NodeReference[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One atomic grouping update. Omitted entries preserve the root list;
|
||||
* supplied entries replace it. Group removal never deletes source Nodes.
|
||||
*/
|
||||
export interface GroupUpdate<Data> {
|
||||
readonly entries?: readonly RenderEntry[]
|
||||
readonly groups:
|
||||
| { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot<Data>[] }
|
||||
| {
|
||||
readonly kind: 'apply'
|
||||
readonly upserts: readonly GroupSnapshot<Data>[]
|
||||
readonly removes: readonly GroupKey[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Target-processed Node values before and after one update. */
|
||||
export interface NodeChange<Node> {
|
||||
readonly previous: Node | undefined
|
||||
readonly current: Node
|
||||
}
|
||||
|
||||
interface GroupInputNodes<Node> {
|
||||
readonly order: readonly NodeKey[]
|
||||
/** @param key - target Node identity. @returns its current value during this synchronous update. */
|
||||
readonly readNode: (key: NodeKey) => Node | undefined
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}
|
||||
|
||||
/** Target-local inputs; apply includes lifecycle changes without Node upserts. */
|
||||
export type ConversationGroupInput<Node> = GroupInputNodes<Node> & (
|
||||
| { readonly kind: 'replace' }
|
||||
| {
|
||||
readonly kind: 'apply'
|
||||
readonly changes: readonly NodeChange<Node>[]
|
||||
readonly changedTurns: readonly number[]
|
||||
}
|
||||
)
|
||||
|
||||
/** Framework-owned State for one Session's registered Group Definition. */
|
||||
export interface ConversationGroupContext<State> {
|
||||
readonly state: State
|
||||
}
|
||||
|
||||
/** Business grouping rules, driven after the target Builder has installed Nodes. */
|
||||
export interface ConversationGroupDefinition<
|
||||
Node extends ConversationViewNode = ConversationViewNode,
|
||||
State = unknown,
|
||||
Data = unknown,
|
||||
> {
|
||||
readonly kind: string
|
||||
readonly target: string
|
||||
/** @returns initial Session-local grouping State. */
|
||||
create(): State
|
||||
/**
|
||||
* Consume target Node changes, not raw Session events or presentation modes.
|
||||
* @param context - current Session-local State.
|
||||
* @param input - current target inputs; readNode is valid only during this call.
|
||||
* @returns State adopted by the framework.
|
||||
*/
|
||||
update(context: ConversationGroupContext<State>, input: ConversationGroupInput<Node>): State
|
||||
/**
|
||||
* Materialize pending grouping changes without changing the business State.
|
||||
* @param context - State adopted after update.
|
||||
* @returns complete output for replace input; an update or null when unchanged for apply input.
|
||||
*/
|
||||
buildGroups(context: ConversationGroupContext<State>): GroupUpdate<Data> | null
|
||||
}
|
||||
|
||||
/** Merge-extensible group data associated with each registered target. */
|
||||
export interface ConversationGroupDataMap {}
|
||||
|
||||
/** Group data declared by a target; undeclared targets have no group payload. */
|
||||
export type ConversationGroupData<Target extends string> =
|
||||
Target extends keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never
|
||||
|
||||
/** Stable grouped reader; data-only updates preserve entries and member arrays. */
|
||||
export interface ConversationGroupedView<Data> {
|
||||
readonly entries: readonly RenderEntry[]
|
||||
/**
|
||||
* Observe a group without subscribing to the root sequence.
|
||||
* @param key - group identity within this target.
|
||||
* @returns an identity-stable source, whose value is absent after removal.
|
||||
*/
|
||||
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined>
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface ConversationSnapshot {
|
||||
|
||||
/** Empty Conversation value used before a Session binding is available. */
|
||||
export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ConversationViewSnapshotStore,
|
||||
} from '../contract/conversation.ts'
|
||||
import { conversationContextKey } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView,
|
||||
} from '../contract/groups.ts'
|
||||
import { ConversationGroupStore } from './group-store.ts'
|
||||
import {
|
||||
ConversationLocationIndex, type ConversationLocationDataChange,
|
||||
} from './location-index.ts'
|
||||
@@ -46,11 +50,18 @@ interface PendingMatch {
|
||||
interface ViewState {
|
||||
readonly target: string
|
||||
readonly definition: ConversationViewDefinition
|
||||
readonly groupDefinition: ConversationGroupDefinition | undefined
|
||||
readonly isActive: ((snapshot: unknown) => boolean) | undefined
|
||||
builder: ConversationViewBuilder | undefined
|
||||
snapshot: unknown
|
||||
}
|
||||
|
||||
interface GroupState {
|
||||
readonly definition: ConversationGroupDefinition
|
||||
state: unknown
|
||||
readonly store: ConversationGroupStore<unknown>
|
||||
}
|
||||
|
||||
const PUBLICATION_RANK: Record<ConversationPublication, number> = {
|
||||
none: 0,
|
||||
'animation-frame': 1,
|
||||
@@ -152,6 +163,16 @@ export interface ConversationViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[]
|
||||
}
|
||||
|
||||
/** Registered grouping rules consumed by the target-neutral assembler. */
|
||||
export interface ConversationGroupDefinitions {
|
||||
/** @returns grouping Definitions in registration order. */
|
||||
entries(): readonly ConversationGroupDefinition[]
|
||||
/** @param target - View target. @returns its optional Group Definition. */
|
||||
forTarget(target: string): ConversationGroupDefinition | undefined
|
||||
}
|
||||
|
||||
const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined }
|
||||
|
||||
/**
|
||||
* Session-owned incremental engine that assembles business Contexts from a
|
||||
* contiguous Event window and materializes registered view snapshots.
|
||||
@@ -168,6 +189,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
private readonly revised = new Set<InternalContext>()
|
||||
private readonly dependents = new Map<string, Set<InternalContext>>()
|
||||
private readonly views = new Map<string, ViewState>()
|
||||
private readonly groups = new Map<string, GroupState>()
|
||||
private readonly pendingGroupStores = new Set<ConversationGroupStore<unknown>>()
|
||||
private readonly activeTargets = new Set<string>()
|
||||
private hasMore = false
|
||||
private replacePending = true
|
||||
@@ -176,10 +199,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
/**
|
||||
* @param eventDefinitions - live Event Definition registry.
|
||||
* @param viewDefinitions - live view builder registry.
|
||||
* @param groupDefinitions - optional registered grouping rules, independent of presentation modes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly eventDefinitions: ConversationEventDefinitions,
|
||||
private readonly viewDefinitions: ConversationViewDefinitions,
|
||||
private readonly groupDefinitions: ConversationGroupDefinitions = NO_GROUPS,
|
||||
) {
|
||||
this.resetViewBuilders()
|
||||
}
|
||||
@@ -333,28 +358,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
|
||||
if (this.replacePending) {
|
||||
this.replaceLocationData()
|
||||
let published = false
|
||||
const updated: ViewState[] = []
|
||||
const changedTurns = this.locationIndex.takeChangedTurns()
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
if (view === undefined) continue
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
this.updateView(view, true, this.buildTargetNodes(target, this.contextsByTarget.get(target)), changedTurns)
|
||||
updated.push(view)
|
||||
}
|
||||
this.locationIndex.publishData()
|
||||
this.replacePending = false
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return published
|
||||
return this.publishViews(updated)
|
||||
}
|
||||
|
||||
let published = false
|
||||
const updated: ViewState[] = []
|
||||
if (this.applyDirtyLocationData()) this.timelineDirty = true
|
||||
const changedTurns = this.locationIndex.takeChangedTurns()
|
||||
const timelineDirty = this.timelineDirty
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
@@ -363,17 +384,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
if (builder === undefined) continue
|
||||
const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target))
|
||||
if (upserts.length === 0 && !timelineDirty) continue
|
||||
view.snapshot = builder.apply({
|
||||
upserts,
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
this.updateView(view, false, upserts, changedTurns)
|
||||
updated.push(view)
|
||||
}
|
||||
this.locationIndex.publishData()
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return published
|
||||
return this.publishViews(updated)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +406,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
this.activeTargets.add(target)
|
||||
if (view === undefined) return published
|
||||
this.replaceView(view)
|
||||
this.publishViews([view])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -407,6 +425,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
|
||||
}
|
||||
|
||||
grouped<Target extends string>(
|
||||
target: Target,
|
||||
): ConversationGroupedView<ConversationGroupData<Target>> | undefined {
|
||||
return this.groups.get(target)?.store as ConversationGroupedView<ConversationGroupData<Target>> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read targets whose owners classify their latest snapshot as visible activity.
|
||||
* @returns target ids contributing visible activity.
|
||||
@@ -827,12 +851,55 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
}
|
||||
|
||||
private replaceView(view: ViewState): void {
|
||||
this.updateView(view, true, this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), [])
|
||||
}
|
||||
|
||||
private updateView(
|
||||
view: ViewState,
|
||||
replacing: boolean,
|
||||
nodes: readonly ConversationViewNode[],
|
||||
changedTurns: readonly number[],
|
||||
): void {
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
const definition = view.groupDefinition
|
||||
if (definition !== undefined && builder.groupInput === undefined) {
|
||||
throw new Error(`conversation group target "${view.target}" requires builder.groupInput()`)
|
||||
}
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
const timeline = this.locationIndex.snapshot()
|
||||
const snapshot = replacing
|
||||
? builder.replace({ nodes, timeline, changedTurns })
|
||||
: builder.apply({ upserts: nodes, timeline, changedTurns })
|
||||
if (definition !== undefined && builder.groupInput !== undefined) {
|
||||
let context = this.groups.get(view.target)
|
||||
const initial = context === undefined
|
||||
if (context === undefined) {
|
||||
context = { definition, state: definition.create(), store: new ConversationGroupStore() }
|
||||
}
|
||||
const input = builder.groupInput()
|
||||
context.state = definition.update(context, input)
|
||||
const change = definition.buildGroups(context)
|
||||
if ((initial || input.kind === 'replace')
|
||||
&& (change === null || change.entries === undefined || change.groups.kind !== 'replace')) {
|
||||
throw new Error(`conversation group target "${view.target}" requires complete grouping for replacement input`)
|
||||
}
|
||||
if (change !== null) {
|
||||
context.store.prepareAndInstall(change, input.readNode)
|
||||
this.pendingGroupStores.add(context.store)
|
||||
}
|
||||
this.groups.set(view.target, context)
|
||||
}
|
||||
view.snapshot = snapshot
|
||||
}
|
||||
|
||||
private publishViews(updated: readonly ViewState[]): boolean {
|
||||
const changed = updated.length > 0 || this.pendingGroupStores.size > 0
|
||||
const stores = [...this.pendingGroupStores]
|
||||
this.pendingGroupStores.clear()
|
||||
for (const view of updated) view.builder?.publish?.()
|
||||
for (const store of stores) store.publish()
|
||||
this.locationIndex.publishData()
|
||||
return changed
|
||||
}
|
||||
|
||||
private buildTargetNodes(
|
||||
@@ -925,11 +992,26 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
}
|
||||
|
||||
private resetViewBuilders(): void {
|
||||
const definitions = this.viewDefinitions.entries()
|
||||
const targets = new Set(definitions.map(definition => definition.target))
|
||||
for (const definition of this.groupDefinitions.entries()) {
|
||||
if (!targets.has(definition.target)) {
|
||||
throw new Error(`conversation group target "${definition.target}" is not registered`)
|
||||
}
|
||||
}
|
||||
for (const [target, group] of this.groups) {
|
||||
if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) {
|
||||
group.store.clear()
|
||||
this.pendingGroupStores.add(group.store)
|
||||
this.groups.delete(target)
|
||||
}
|
||||
}
|
||||
this.views.clear()
|
||||
for (const definition of this.viewDefinitions.entries()) {
|
||||
for (const definition of definitions) {
|
||||
const view: ViewState = {
|
||||
target: definition.target,
|
||||
definition,
|
||||
groupDefinition: this.groupDefinitions.forTarget(definition.target),
|
||||
isActive: definition.isActive === undefined
|
||||
? undefined
|
||||
: snapshot => definition.isActive?.(snapshot) === true,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts'
|
||||
import { ConversationEventRegistry } from './event-registry.ts'
|
||||
import { HistoricalImageCache } from './historical-images.ts'
|
||||
import { ConversationViewRegistry } from './view-registry.ts'
|
||||
import { ConversationGroupRegistry } from './group-registry.ts'
|
||||
|
||||
/** Observable faces published for one Session's Conversation assembly. */
|
||||
export interface ConversationBinding {
|
||||
@@ -178,6 +179,8 @@ export class UiConversation extends Service {
|
||||
readonly events: ConversationEventRegistry
|
||||
/** Registry of target View definitions. */
|
||||
readonly views: ConversationViewRegistry
|
||||
/** Business grouping rules over already materialized target Nodes. */
|
||||
readonly groups: ConversationGroupRegistry
|
||||
private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
|
||||
private readonly images: HistoricalImageCache
|
||||
|
||||
@@ -189,6 +192,7 @@ export class UiConversation extends Service {
|
||||
super(ctx, 'uiConversation')
|
||||
this.events = new ConversationEventRegistry(ctx)
|
||||
this.views = new ConversationViewRegistry(ctx)
|
||||
this.groups = new ConversationGroupRegistry(ctx, this.views)
|
||||
this.images = new HistoricalImageCache(ctx, sessions)
|
||||
const rebuild = (): void => {
|
||||
for (const record of this.bindings.values) record.binding.rebuild()
|
||||
@@ -205,7 +209,9 @@ export class UiConversation extends Service {
|
||||
ctx.effect(() => {
|
||||
const disposeEvents = this.events.subscribe(scheduleRebuild)
|
||||
const disposeViews = this.views.subscribe(scheduleRebuild)
|
||||
const disposeGroups = this.groups.subscribe(scheduleRebuild)
|
||||
return () => {
|
||||
disposeGroups()
|
||||
disposeViews()
|
||||
disposeEvents()
|
||||
for (const record of [...this.bindings.values]) this.drop(record, true)
|
||||
@@ -229,7 +235,7 @@ export class UiConversation extends Service {
|
||||
if (current !== undefined) return current.binding
|
||||
const binding = new BoundConversation(
|
||||
owner.eventSource,
|
||||
new ConversationNodeAssembler(this.events, this.views),
|
||||
new ConversationNodeAssembler(this.events, this.views, this.groups),
|
||||
)
|
||||
const record: BindingRecord = { source: owner, binding, disposeScope: () => {} }
|
||||
this.bindings.set(owner, record)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Target-indexed business grouping registrations with the existing effect lifecycle. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConversationViewNode } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupDefinition,
|
||||
} from '../contract/groups.ts'
|
||||
import { ConversationDefinitionRegistry } from './definition-registry.ts'
|
||||
import type { ConversationViewRegistry } from './view-registry.ts'
|
||||
|
||||
/** One Group Definition per target; registration never instantiates its Builder. */
|
||||
export class ConversationGroupRegistry extends ConversationDefinitionRegistry<ConversationGroupDefinition> {
|
||||
/**
|
||||
* @param ctx - owning plugin context.
|
||||
* @param views - registered target Builder definitions.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly views: ConversationViewRegistry) {
|
||||
super(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register grouping rules for an existing target.
|
||||
* @param definition - business State and grouping output for the declared target data.
|
||||
* @returns the effect-owned, idempotent registration disposer.
|
||||
*/
|
||||
register<Node extends ConversationViewNode, State, Target extends string>(
|
||||
definition: ConversationGroupDefinition<Node, State, ConversationGroupData<Target>>
|
||||
& { readonly target: Target },
|
||||
): () => void {
|
||||
if (!this.views.entries().some(view => view.target === definition.target)) {
|
||||
throw new Error(`conversation group target "${definition.target}" is not registered`)
|
||||
}
|
||||
return this.registerDefinition(
|
||||
definition.target,
|
||||
definition,
|
||||
`conversation group target "${definition.target}" is already registered`,
|
||||
`uiConversation.groups.register(${JSON.stringify(definition.target)})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the grouping rules registered for one target.
|
||||
* @param target - View target.
|
||||
* @returns its grouping Definition, when registered.
|
||||
*/
|
||||
forTarget(target: string): ConversationGroupDefinition | undefined {
|
||||
return this.definitions.get(target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/** Keyed group publication and incremental validation of rendering positions. */
|
||||
import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ConversationViewNode } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupedView, GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry,
|
||||
} from '../contract/groups.ts'
|
||||
|
||||
type Parts = Set<string | undefined>
|
||||
|
||||
interface GroupSource<Data> {
|
||||
readonly observable: ObservableSnapshot<GroupSnapshot<Data> | undefined>
|
||||
readonly publication: SnapshotStore<GroupSnapshot<Data> | undefined>
|
||||
}
|
||||
|
||||
interface GroupChanges<Data> {
|
||||
readonly upserts: Map<GroupKey, GroupSnapshot<Data>>
|
||||
readonly removes: Set<GroupKey>
|
||||
}
|
||||
|
||||
function sameNodeReference(left: NodeReference, right: NodeReference): boolean {
|
||||
return left.key === right.key && left.groupPart === right.groupPart
|
||||
}
|
||||
|
||||
function sameEntry(left: RenderEntry, right: RenderEntry): boolean {
|
||||
return left.kind === right.kind && left.key === right.key
|
||||
&& (left.kind === 'group' || (right.kind === 'node' && left.groupPart === right.groupPart))
|
||||
}
|
||||
|
||||
function reuseReferences<Value>(
|
||||
previous: readonly Value[],
|
||||
next: readonly Value[],
|
||||
equal: (left: Value, right: Value) => boolean,
|
||||
): readonly Value[] {
|
||||
return previous === next || (previous.length === next.length
|
||||
&& previous.every((value, index) => equal(value, next[index] as Value)))
|
||||
? previous
|
||||
: next
|
||||
}
|
||||
|
||||
/** Validates a complete batch before installing its group and root-list changes. */
|
||||
export class ConversationGroupStore<Data> implements ConversationGroupedView<Data> {
|
||||
private root: readonly RenderEntry[] = []
|
||||
private rootGroups = new Set<GroupKey>()
|
||||
private readonly groups = new Map<GroupKey, GroupSnapshot<Data>>()
|
||||
private readonly placements = new Map<NodeKey, Parts>()
|
||||
private readonly sources = new Map<GroupKey, GroupSource<Data>>()
|
||||
private readonly dirty = new Set<GroupKey>()
|
||||
|
||||
/** @returns the identity-stable ordered root references. */
|
||||
get entries(): readonly RenderEntry[] {
|
||||
return this.root
|
||||
}
|
||||
|
||||
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined> {
|
||||
let source = this.sources.get(key)
|
||||
if (source === undefined) {
|
||||
const publication = createSnapshotStore(this.groups.get(key))
|
||||
source = {
|
||||
publication,
|
||||
observable: {
|
||||
getSnapshot: () => this.groups.get(key),
|
||||
subscribe: listener => publication.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.sources.set(key, source)
|
||||
}
|
||||
return source.observable
|
||||
}
|
||||
|
||||
/**
|
||||
* Install one validated update without notifying readers.
|
||||
* @param update - root replacement and complete or incremental group records.
|
||||
* @param readNode - synchronous reader of the current target Nodes.
|
||||
*/
|
||||
prepareAndInstall(
|
||||
update: GroupUpdate<Data>,
|
||||
readNode: (key: NodeKey) => ConversationViewNode | undefined,
|
||||
): void {
|
||||
const nextRoot = update.entries === undefined
|
||||
? this.root
|
||||
: reuseReferences(this.root, update.entries, sameEntry)
|
||||
const nextRootGroups = nextRoot === this.root ? this.rootGroups : this.collectRootGroups(nextRoot)
|
||||
const { upserts, removes } = this.collectChanges(update)
|
||||
const replaceReferences = update.groups.kind === 'replace'
|
||||
const size = this.groups.size - removes.size
|
||||
+ [...upserts.keys()].filter(key => !this.groups.has(key)).length
|
||||
if (size !== nextRootGroups.size) {
|
||||
throw new Error('conversation group records and root references must correspond one-to-one')
|
||||
}
|
||||
for (const key of removes) {
|
||||
if (nextRootGroups.has(key)) throw new Error(`conversation group "${key}" is still referenced`)
|
||||
}
|
||||
for (const key of upserts.keys()) {
|
||||
if (!nextRootGroups.has(key)) throw new Error(`conversation group "${key}" has no root reference`)
|
||||
}
|
||||
if (nextRootGroups !== this.rootGroups) {
|
||||
for (const key of nextRootGroups) {
|
||||
if (!upserts.has(key) && !this.groups.has(key)) {
|
||||
throw new Error(`conversation root references missing group "${key}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const affectedParts = new Map<NodeKey, Parts>()
|
||||
const partsOf = (key: NodeKey): Parts => {
|
||||
let parts = affectedParts.get(key)
|
||||
if (parts === undefined) {
|
||||
parts = new Set(this.placements.get(key))
|
||||
affectedParts.set(key, parts)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
const removeReference = (reference: NodeReference): void => {
|
||||
partsOf(reference.key).delete(reference.groupPart)
|
||||
}
|
||||
const addReference = (reference: NodeReference): void => {
|
||||
if (readNode(reference.key) === undefined) {
|
||||
throw new Error(`conversation group references missing Node "${reference.key}"`)
|
||||
}
|
||||
const parts = partsOf(reference.key)
|
||||
if (parts.has(reference.groupPart)
|
||||
|| (reference.groupPart === undefined && parts.size > 0)
|
||||
|| parts.has(undefined)) {
|
||||
throw new Error(`conversation Node "${reference.key}" has overlapping rendering positions`)
|
||||
}
|
||||
parts.add(reference.groupPart)
|
||||
}
|
||||
|
||||
if (replaceReferences || nextRoot !== this.root) {
|
||||
for (const entry of this.root) if (entry.kind === 'node') removeReference(entry)
|
||||
}
|
||||
for (const key of removes) {
|
||||
for (const member of (this.groups.get(key) as GroupSnapshot<Data>).members) removeReference(member)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
const previous = this.groups.get(key)
|
||||
if (previous !== undefined && (replaceReferences || previous.members !== next.members)) {
|
||||
for (const member of previous.members) removeReference(member)
|
||||
}
|
||||
}
|
||||
if (replaceReferences || nextRoot !== this.root) {
|
||||
for (const entry of nextRoot) if (entry.kind === 'node') addReference(entry)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
if (replaceReferences || this.groups.get(key)?.members !== next.members) {
|
||||
for (const member of next.members) addReference(member)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, parts] of affectedParts) {
|
||||
if (parts.size === 0) this.placements.delete(key)
|
||||
else this.placements.set(key, parts)
|
||||
}
|
||||
for (const key of removes) {
|
||||
this.groups.delete(key)
|
||||
this.dirty.add(key)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
if (this.groups.get(key) === next) continue
|
||||
this.groups.set(key, next)
|
||||
this.dirty.add(key)
|
||||
}
|
||||
this.root = nextRoot
|
||||
this.rootGroups = nextRootGroups
|
||||
}
|
||||
|
||||
/** Publish changed group sources after all related target data has been installed. */
|
||||
publish(): void {
|
||||
const keys = [...this.dirty]
|
||||
this.dirty.clear()
|
||||
for (const key of keys) this.sources.get(key)?.publication.set(this.groups.get(key))
|
||||
}
|
||||
|
||||
/** Remove grouping without deleting its source Nodes; publication remains deferred. */
|
||||
clear(): void {
|
||||
/* v8 ignore next -- an empty replacement never reads a Node reference. */
|
||||
this.prepareAndInstall({ entries: [], groups: { kind: 'replace', snapshots: [] } }, () => undefined)
|
||||
}
|
||||
|
||||
private collectRootGroups(entries: readonly RenderEntry[]): Set<GroupKey> {
|
||||
const keys = new Set<GroupKey>()
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'group') continue
|
||||
if (keys.has(entry.key)) throw new Error(`conversation group "${entry.key}" has duplicate root references`)
|
||||
keys.add(entry.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
private collectChanges(update: GroupUpdate<Data>): GroupChanges<Data> {
|
||||
const upserts = new Map<GroupKey, GroupSnapshot<Data>>()
|
||||
const removes = new Set<GroupKey>()
|
||||
const add = (snapshot: GroupSnapshot<Data>): void => {
|
||||
if (upserts.has(snapshot.key)) throw new Error(`conversation group "${snapshot.key}" has duplicate upserts`)
|
||||
const previous = this.groups.get(snapshot.key)
|
||||
if (previous === undefined) {
|
||||
upserts.set(snapshot.key, snapshot)
|
||||
return
|
||||
}
|
||||
const members = reuseReferences(previous.members, snapshot.members, sameNodeReference)
|
||||
upserts.set(snapshot.key, previous.data === snapshot.data && previous.members === members
|
||||
? previous
|
||||
: { ...snapshot, members })
|
||||
}
|
||||
switch (update.groups.kind) {
|
||||
case 'replace':
|
||||
for (const snapshot of update.groups.snapshots) add(snapshot)
|
||||
for (const key of this.groups.keys()) if (!upserts.has(key)) removes.add(key)
|
||||
break
|
||||
case 'apply':
|
||||
for (const snapshot of update.groups.upserts) add(snapshot)
|
||||
for (const key of update.groups.removes) {
|
||||
if (removes.has(key)) throw new Error(`conversation group "${key}" has duplicate removals`)
|
||||
if (!this.groups.has(key)) throw new Error(`conversation group "${key}" cannot be removed because it is absent`)
|
||||
if (upserts.has(key)) throw new Error(`conversation group "${key}" cannot be upserted and removed together`)
|
||||
removes.add(key)
|
||||
}
|
||||
break
|
||||
/* v8 ignore next 2 -- closed update union; TypeScript rejects other operation tags. */
|
||||
default:
|
||||
assertNever(update.groups)
|
||||
}
|
||||
return { upserts, removes }
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ export class ConversationLocationIndex {
|
||||
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
|
||||
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
|
||||
private readonly dirtyDataStores = new Set<MutableLocationDataStore>()
|
||||
private readonly changedTurns = new Set<number>()
|
||||
private currentTurn: number | undefined
|
||||
private currentStep: number | undefined
|
||||
|
||||
@@ -194,6 +195,16 @@ export class ConversationLocationIndex {
|
||||
return this.timeline
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the Turn changes accumulated for one assembly flush.
|
||||
* @returns Turn identities changed since the preceding drain.
|
||||
*/
|
||||
takeChangedTurns(): readonly number[] {
|
||||
const turns = [...this.changedTurns]
|
||||
this.changedTurns.clear()
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all Definition-owned Location values while preserving reader identities.
|
||||
* @param entries - complete current set of Definition-owned Location values.
|
||||
@@ -386,6 +397,9 @@ export class ConversationLocationIndex {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const turn of new Set([...previousTurns.keys(), ...nextTurns.keys()])) {
|
||||
if (previousTurns.get(turn) !== nextTurns.get(turn)) this.changedTurns.add(turn)
|
||||
}
|
||||
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
|
||||
? this.timeline
|
||||
: { turnOrder, turns: nextTurns }
|
||||
@@ -480,6 +494,7 @@ export class ConversationLocationIndex {
|
||||
? [...this.timeline.turnOrder, turnNumber]
|
||||
: this.timeline.turnOrder
|
||||
this.timeline = { turnOrder, turns }
|
||||
this.changedTurns.add(turnNumber)
|
||||
|
||||
const changed = new Set<number>()
|
||||
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
|
||||
@@ -572,19 +587,22 @@ export class ConversationLocationIndex {
|
||||
}
|
||||
|
||||
private mutableTurnData(turn: number): MutableLocationDataStore {
|
||||
const current = this.turnDataStores.get(turn) ?? this.createDataStore()
|
||||
const current = this.turnDataStores.get(turn) ?? this.createDataStore(turn)
|
||||
this.turnDataStores.set(turn, current)
|
||||
return current
|
||||
}
|
||||
|
||||
private mutableStepData(key: string): MutableLocationDataStore {
|
||||
const current = this.stepDataStores.get(key) ?? this.createDataStore()
|
||||
const current = this.stepDataStores.get(key) ?? this.createDataStore(Number(key.slice(0, key.indexOf(':'))))
|
||||
this.stepDataStores.set(key, current)
|
||||
return current
|
||||
}
|
||||
|
||||
private createDataStore(): MutableLocationDataStore {
|
||||
return new MutableLocationDataStore(store => this.dirtyDataStores.add(store))
|
||||
private createDataStore(turn: number): MutableLocationDataStore {
|
||||
return new MutableLocationDataStore((store) => {
|
||||
this.dirtyDataStores.add(store)
|
||||
this.changedTurns.add(turn)
|
||||
})
|
||||
}
|
||||
|
||||
private storeFor(data: ConversationLocationData): MutableLocationDataStore {
|
||||
|
||||
@@ -3,6 +3,13 @@ export { apply, Config, inject } from './apply.ts'
|
||||
export type { Config as ConversationConfig } from './apply.ts'
|
||||
export { UiConversation } from './conversation/assembly.ts'
|
||||
export type { ConversationBinding } from './conversation/assembly.ts'
|
||||
export type { ConversationGroupRegistry } from './conversation/group-registry.ts'
|
||||
export type {
|
||||
ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap,
|
||||
ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput,
|
||||
GroupKey, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey,
|
||||
NodeReference, RenderEntry,
|
||||
} from './contract/groups.ts'
|
||||
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
export type {
|
||||
@@ -40,7 +47,7 @@ export type { ConversationStoreState, ConversationViewRequest, ViewTab } from '.
|
||||
|
||||
export { ConversationNodeAssembler } from './conversation/assembler.ts'
|
||||
export type {
|
||||
ConversationEventDefinitions, ConversationViewDefinitions,
|
||||
ConversationEventDefinitions, ConversationGroupDefinitions, ConversationViewDefinitions,
|
||||
} from './conversation/assembler.ts'
|
||||
export { ConversationDefinitionRegistry } from './conversation/definition-registry.ts'
|
||||
export { ConversationEventRegistry } from './conversation/event-registry.ts'
|
||||
|
||||
@@ -177,6 +177,8 @@ describe('ConversationNodeAssembler', () => {
|
||||
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
|
||||
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })
|
||||
index.rebuild([input(turnStart), input(stepStart)])
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
expect(index.takeChangedTurns()).toEqual([])
|
||||
const location = index.locationOf(stepStart)
|
||||
if (location.kind !== 'step') throw new Error('scope probe requires a Step Location')
|
||||
const source = location.step.data.source('scope-probe')
|
||||
@@ -192,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
}])).toBe(true)
|
||||
expect(source.getSnapshot()).toBe(initial)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
|
||||
index.publishData()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
@@ -204,6 +207,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
}])
|
||||
index.publishData()
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
|
||||
const changed = { value: 2 }
|
||||
index.replaceData([{
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { ConversationViewNode } from '../src/client/contract/conversation.ts'
|
||||
import type { GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry } from '../src/client/contract/groups.ts'
|
||||
import { ConversationGroupStore } from '../src/client/conversation/group-store.ts'
|
||||
|
||||
const key = (value: string): GroupKey => brandString<GroupKey>(value)
|
||||
const node = (value: string, groupPart?: string): NodeReference => ({
|
||||
kind: 'node', key: brandString<NodeKey>(value), ...groupPart === undefined ? {} : { groupPart },
|
||||
})
|
||||
const reference = (value: string): RenderEntry => ({ kind: 'group', key: key(value) })
|
||||
const readNode = (id: NodeKey): ConversationViewNode | undefined => id === 'missing'
|
||||
? undefined
|
||||
: { key: id, kind: 'test', id, target: 'test', data: id }
|
||||
const group = (id: string, members: readonly NodeReference[], data = 0): GroupSnapshot<number> => ({ key: key(id), members, data })
|
||||
|
||||
function replace(store: ConversationGroupStore<number>, entries: readonly RenderEntry[], groups: readonly GroupSnapshot<number>[]) {
|
||||
store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: groups } }, readNode)
|
||||
}
|
||||
|
||||
describe('ConversationGroupStore', () => {
|
||||
it('publishes one changed group without changing root or member identities', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const a = group('a', [node('a')])
|
||||
const b = group('b', [node('b')])
|
||||
replace(store, [reference('a'), node('reply'), reference('b')], [a, b])
|
||||
store.publish()
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
const other = store.groupSource(key('b'))
|
||||
expect(store.groupSource(key('a'))).toBe(source)
|
||||
const changed = vi.fn()
|
||||
const otherChanged = vi.fn()
|
||||
const unsubscribe = source.subscribe(changed)
|
||||
const unsubscribeOther = other.subscribe(otherChanged)
|
||||
const reads = vi.fn(readNode)
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 1 }], removes: [] } }, reads)
|
||||
expect(source.getSnapshot()).toEqual({ ...a, data: 1 })
|
||||
expect(source.getSnapshot()?.members).toBe(a.members)
|
||||
expect(other.getSnapshot()).toBe(b)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(reads).not.toHaveBeenCalled()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(otherChanged).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
unsubscribeOther()
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 2 }], removes: [] } }, readNode)
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reuses equivalent snapshots and accepts parts in separate positions', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const reasoning = node('assistant', 'reasoning')
|
||||
const reply = node('assistant', 'response')
|
||||
const original = group('a', [reasoning])
|
||||
replace(store, [reference('a'), reply], [original])
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
store.publish()
|
||||
replace(store, [reference('a'), node('assistant', 'response')], [group('a', [node('assistant', 'reasoning')])])
|
||||
store.publish()
|
||||
expect(store.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(original)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [group('a', [node('assistant', 'reasoning')], 1)], removes: [] } }, readNode)
|
||||
expect(source.getSnapshot()?.members).toBe(original.members)
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('moves references atomically and preserves surviving group sources', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const a = node('a')
|
||||
const b = node('b')
|
||||
replace(store, [reference('old')], [group('old', [a, b])])
|
||||
store.publish()
|
||||
const oldSource = store.groupSource(key('old'))
|
||||
const nextSource = store.groupSource(key('next'))
|
||||
const oldChanged = vi.fn()
|
||||
oldSource.subscribe(oldChanged)
|
||||
store.prepareAndInstall({
|
||||
entries: [a, reference('next')],
|
||||
groups: { kind: 'apply', removes: [key('old')], upserts: [group('next', [b])] },
|
||||
}, readNode)
|
||||
expect(oldSource.getSnapshot()).toBeUndefined()
|
||||
expect(nextSource.getSnapshot()?.members).toEqual([b])
|
||||
expect(oldChanged).not.toHaveBeenCalled()
|
||||
store.publish()
|
||||
expect(oldChanged).toHaveBeenCalledOnce()
|
||||
const root = store.entries
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [group('next', [node('c')])] } }, readNode)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(store.groupSource(key('next'))).toBe(nextSource)
|
||||
store.prepareAndInstall({ entries: [node('c'), a], groups: { kind: 'apply', removes: [key('next')], upserts: [] } }, readNode)
|
||||
store.publish()
|
||||
expect(store.entries).toEqual([node('c'), a])
|
||||
store.clear()
|
||||
store.publish()
|
||||
expect(store.entries).toEqual([])
|
||||
expect(nextSource.getSnapshot()).toBeUndefined()
|
||||
store.clear()
|
||||
store.publish()
|
||||
})
|
||||
|
||||
it('keeps omitted entries and allows an empty complete replacement', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
replace(store, [node('a'), node('b')], [])
|
||||
const root = store.entries
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [], removes: [] } }, readNode)
|
||||
expect(store.entries).toBe(root)
|
||||
store.prepareAndInstall({ entries: [node('b'), node('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, readNode)
|
||||
expect(store.entries).toEqual([node('b'), node('a')])
|
||||
replace(store, [], [])
|
||||
expect(store.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('validates references again on complete replacement, even when arrays are reused', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const initial = group('a', [node('a')])
|
||||
const entries = [reference('a'), node('b')]
|
||||
replace(store, entries, [initial])
|
||||
expect(() =>{ store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) })
|
||||
.toThrow('missing Node')
|
||||
expect(store.entries).toBe(entries)
|
||||
expect(store.groupSource(key('a')).getSnapshot()).toBe(initial)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[node('a'), node('a')],
|
||||
[node('a', 'reasoning'), node('a', 'reasoning')],
|
||||
[node('a'), node('a', 'reasoning')],
|
||||
[node('a', 'reasoning'), node('a')],
|
||||
])('rejects overlapping Node positions without changing installed data: %j', (first, second) => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
replace(store, [node('before')], [])
|
||||
const root = store.entries
|
||||
expect(() =>{ replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping')
|
||||
expect(store.entries).toBe(root)
|
||||
replace(store, [reference('a')], [group('a', [first])])
|
||||
expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first])
|
||||
})
|
||||
|
||||
it.each<{ name: string; update: GroupUpdate<number>; error: string }>([
|
||||
{ name: 'duplicate group roots', update: { entries: [reference('a'), reference('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'duplicate root' },
|
||||
{ name: 'unreferenced group', update: { entries: [reference('a'), reference('c')], groups: { kind: 'apply', upserts: [group('b', [])], removes: [] } }, error: 'no root reference' },
|
||||
{ name: 'missing group', update: { entries: [reference('missing')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'missing group' },
|
||||
{ name: 'removed referenced group', update: { groups: { kind: 'apply', upserts: [group('b', [])], removes: [key('a')] } }, error: 'still referenced' },
|
||||
{ name: 'record count mismatch', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'one-to-one' },
|
||||
{ name: 'duplicate upserts', update: { groups: { kind: 'apply', upserts: [group('a', []), group('a', [])], removes: [] } }, error: 'duplicate upserts' },
|
||||
{ name: 'duplicate removals', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [key('a'), key('a')] } }, error: 'duplicate removals' },
|
||||
{ name: 'absent removal', update: { groups: { kind: 'apply', upserts: [], removes: [key('missing')] } }, error: 'absent' },
|
||||
{ name: 'upsert and remove', update: { groups: { kind: 'apply', upserts: [group('a', [])], removes: [key('a')] } }, error: 'upserted and removed' },
|
||||
{ name: 'missing Node', update: { groups: { kind: 'apply', upserts: [group('a', [node('missing')])], removes: [] } }, error: 'missing Node' },
|
||||
])('rejects $name atomically', ({ update, error }) => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const initial = group('a', [node('a')])
|
||||
replace(store, [reference('a')], [initial])
|
||||
store.publish()
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
expect(() =>{ store.prepareAndInstall(update, readNode) }).toThrow(error)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(initial)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type {
|
||||
ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupDefinition, ConversationGroupInput, GroupKey, NodeKey,
|
||||
} from '../src/client/contract/groups.ts'
|
||||
import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts'
|
||||
import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationGroupDataMap {
|
||||
'group-test': number
|
||||
}
|
||||
}
|
||||
|
||||
const groupKey = brandString<GroupKey>('group')
|
||||
const noEvents = { entries: () => [], fallbackEntry: () => undefined }
|
||||
|
||||
class GroupTestBuilder implements ConversationViewBuilder<ConversationViewNode, ReadonlyMap<string, ConversationViewNode>> {
|
||||
readonly empty = new Map<string, ConversationViewNode>()
|
||||
readonly nodes = new Map<string, ConversationViewNode>()
|
||||
readonly publish = vi.fn()
|
||||
private input: ConversationGroupInput<ConversationViewNode> = {
|
||||
kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() },
|
||||
}
|
||||
|
||||
replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) {
|
||||
this.nodes.clear()
|
||||
for (const node of nodes) this.nodes.set(node.key, node)
|
||||
this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline }
|
||||
return this.nodes
|
||||
}
|
||||
|
||||
apply({ upserts, timeline, changedTurns = [] }: {
|
||||
upserts: readonly ConversationViewNode[]
|
||||
timeline: ConversationTimelineSnapshot
|
||||
changedTurns?: readonly number[]
|
||||
}) {
|
||||
const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current }))
|
||||
for (const node of upserts) this.nodes.set(node.key, node)
|
||||
this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline }
|
||||
return this.nodes
|
||||
}
|
||||
|
||||
groupInput() { return this.input }
|
||||
}
|
||||
|
||||
function grouping(seen: ConversationGroupInput<ConversationViewNode>[] = []) {
|
||||
return {
|
||||
kind: 'test-group',
|
||||
target: 'group-test' as const,
|
||||
create: (): ConversationGroupInput<ConversationViewNode> | null => null,
|
||||
update: (_context, input) => { seen.push(input); return input },
|
||||
buildGroups: ({ state }) => state === null ? null : {
|
||||
entries: [{ kind: 'group', key: groupKey }],
|
||||
groups: {
|
||||
kind: 'replace',
|
||||
snapshots: [{
|
||||
key: groupKey,
|
||||
data: state.timeline.turns.get(1)?.status === 'closed' ? 1 : 0,
|
||||
members: state.order.map(key => ({ kind: 'node', key })),
|
||||
}],
|
||||
},
|
||||
},
|
||||
} satisfies ConversationGroupDefinition<ConversationViewNode, ConversationGroupInput<ConversationViewNode> | null, number>
|
||||
}
|
||||
|
||||
function start(seq: number, turn = 1): SessionEvent<'turn/start'> {
|
||||
return { seq: SessionSeq(seq), time: seq, type: 'turn/start', data: { turn } }
|
||||
}
|
||||
|
||||
function setup(definition: ConversationGroupDefinition = grouping()) {
|
||||
const builder = new GroupTestBuilder()
|
||||
const create = vi.fn(() => builder)
|
||||
let groupDefinitions = [definition]
|
||||
const views = [{ target: 'group-test', create }]
|
||||
const groups = {
|
||||
entries: () => groupDefinitions,
|
||||
forTarget: (target: string) => groupDefinitions.find(candidate => candidate.target === target),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => views }, groups)
|
||||
return {
|
||||
assembler, builder, create, views,
|
||||
removeGroups: () => { groupDefinitions = [] },
|
||||
replaceGroup: (next: ConversationGroupDefinition) => { groupDefinitions = [next] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('group Definition dispatch', () => {
|
||||
it('creates grouping on first activation without eagerly constructing a Builder', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, builder, create } = setup(grouping(seen))
|
||||
assembler.replaceWindow([{ type: 'event', event: start(1) }], false)
|
||||
expect(assembler.flush()).toBe(false)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(assembler.grouped('group-test')).toBeUndefined()
|
||||
builder.publish.mockImplementation(() => {
|
||||
expect(assembler.snapshot('group-test')).toBe(builder.nodes)
|
||||
expect(assembler.grouped('group-test')?.groupSource(groupKey).getSnapshot()?.data).toBe(0)
|
||||
})
|
||||
expect(assembler.activateTarget('group-test')).toBe(true)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(seen.map(input => input.kind)).toEqual(['replace'])
|
||||
expect(builder.publish).toHaveBeenCalledOnce()
|
||||
expect(assembler.activateTarget('group-test')).toBe(false)
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers lifecycle changes without Node upserts and installs groups before publication', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, builder } = setup(grouping(seen))
|
||||
assembler.replaceWindow([{ type: 'event', event: start(1) }], false)
|
||||
assembler.activateTarget('group-test')
|
||||
const grouped = assembler.grouped('group-test')!
|
||||
const entries = grouped.entries
|
||||
const source = grouped.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
const end: SessionEvent<'turn/end'> = { type: 'turn/end', seq: SessionSeq(2), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }
|
||||
assembler.append({ type: 'event', event: end })
|
||||
builder.publish.mockImplementation(() => { expect(source.getSnapshot()?.data).toBe(1) })
|
||||
assembler.flush()
|
||||
const input = seen.at(-1)!
|
||||
expect(input.kind).toBe('apply')
|
||||
if (input.kind !== 'apply') throw new Error('expected apply')
|
||||
expect(input.changes).toEqual([])
|
||||
expect(input.changedTurns).toEqual([1])
|
||||
expect(grouped.entries).toBe(entries)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(assembler.flush()).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds through the same path and invalidates sources when grouping is removed', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, removeGroups } = setup(grouping(seen))
|
||||
assembler.activateTarget('group-test')
|
||||
const source = assembler.grouped('group-test')!.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(seen.map(input => input.kind)).toEqual(['replace', 'replace'])
|
||||
expect(assembler.grouped('group-test')!.groupSource(groupKey)).toBe(source)
|
||||
removeGroups()
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
expect(assembler.grouped('group-test')).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not ask ungrouped Builders for grouping input', () => {
|
||||
const builder = new GroupTestBuilder()
|
||||
const input = vi.spyOn(builder, 'groupInput')
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create: () => builder }] })
|
||||
assembler.activateTarget('group-test')
|
||||
expect(input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the installed group context until the registry rebuild replaces it', () => {
|
||||
const first = grouping()
|
||||
const firstUpdate = vi.spyOn(first, 'update')
|
||||
const next = grouping()
|
||||
const nextCreate = vi.spyOn(next, 'create')
|
||||
const { assembler, replaceGroup } = setup(first)
|
||||
assembler.activateTarget('group-test')
|
||||
const source = assembler.grouped('group-test')!.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
replaceGroup(next)
|
||||
assembler.append({ type: 'event', event: start(1) })
|
||||
assembler.flush()
|
||||
expect(firstUpdate).toHaveBeenCalledTimes(2)
|
||||
expect(nextCreate).not.toHaveBeenCalled()
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(nextCreate).toHaveBeenCalledOnce()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(assembler.grouped('group-test')!.groupSource(groupKey)).not.toBe(source)
|
||||
})
|
||||
|
||||
it('keeps published grouping when a later buildGroups returns null', () => {
|
||||
const definition = grouping()
|
||||
const build = vi.spyOn(definition, 'buildGroups')
|
||||
const { assembler } = setup(definition)
|
||||
assembler.activateTarget('group-test')
|
||||
const grouped = assembler.grouped('group-test')!
|
||||
const root = grouped.entries
|
||||
const source = grouped.groupSource(groupKey)
|
||||
const value = source.getSnapshot()
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
build.mockReturnValueOnce(null)
|
||||
assembler.append({ type: 'event', event: start(1) })
|
||||
assembler.flush()
|
||||
expect(grouped.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(value)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing Builder input method only after target activation', () => {
|
||||
const create = vi.fn(() => ({ empty: null, replace: () => null, apply: () => null }))
|
||||
const definition = grouping()
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create }] }, {
|
||||
entries: () => [definition], forTarget: () => definition,
|
||||
})
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()')
|
||||
})
|
||||
|
||||
it('rejects missing targets and incomplete initial output', () => {
|
||||
const definition = grouping()
|
||||
expect(() => new ConversationNodeAssembler(noEvents, { entries: () => [] }, {
|
||||
entries: () => [definition], forTarget: () => definition,
|
||||
})).toThrow('is not registered')
|
||||
const missing = setup({ ...definition, buildGroups: () => null })
|
||||
expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input')
|
||||
const retained = grouping()
|
||||
const build = vi.spyOn(retained, 'buildGroups')
|
||||
const { assembler } = setup(retained)
|
||||
assembler.activateTarget('group-test')
|
||||
build.mockReturnValueOnce(null)
|
||||
assembler.replaceWindow([], false)
|
||||
expect(() => assembler.flush()).toThrow('complete grouping for replacement input')
|
||||
})
|
||||
|
||||
it('registers grouping without creating its Builder and disposes it with its effect', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const views = new ConversationViewRegistry(ctx)
|
||||
const groups = new ConversationGroupRegistry(ctx, views)
|
||||
const definition = grouping()
|
||||
expect(() => groups.register(definition)).toThrow('is not registered')
|
||||
const create = vi.fn(() => new GroupTestBuilder())
|
||||
views.register({ target: 'group-test', create } satisfies ConversationViewDefinition)
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = groups.subscribe(changed)
|
||||
const dispose = groups.register(definition)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(groups.forTarget('group-test')).toBe(definition)
|
||||
expect(() => groups.register(definition)).toThrow('already registered')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(groups.forTarget('group-test')).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
groups.register(definition)
|
||||
await ctx.fiber.dispose()
|
||||
expect(groups.entries()).toEqual([])
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -64,7 +64,7 @@ const workspaceState = {
|
||||
error: null,
|
||||
}
|
||||
const conversationState: ConversationState = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
const emptyKeys: readonly string[] = []
|
||||
|
||||
@@ -62,7 +62,7 @@ const workspaceState = {
|
||||
error: null,
|
||||
}
|
||||
const conversationState: ConversationState = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
const emptyKeys: readonly string[] = []
|
||||
|
||||
Reference in new issue
Block a user