Exclusive Conversation matches
Conversation 独占匹配
Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target. The late question reply is the shipped case. Chat's gen
English
Problem
Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target.
The late question reply is the shipped case. Chat's generic input-message Definition matches every appended user/message, and the questions UI's question-reply Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate turn-trigger presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice.
Decision
A match result may claim its event on the Definition's target:
interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
readonly exclusive?: true
}
- Dispatch.
ConversationNodeAssembler.dispatchInput()evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch. - Failures. Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its
seq, and the claiming Definition kinds. - Scope. A claim belongs to one event and comes from
match(), so a Definition claims only the events it recognizes. The generic Definition declares nothing. - Current use.
question-replyreturnsexclusive: truefor a recordeduser-question-replymessage. Chat no longer aggregates by message id, and the reply's placement is unchanged.
Alternatives considered
Definition precedence (specific / default). A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking.
Declare human sources in a registry and render the reply through user-text rules. The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition.
Mark human-authored sources in durable data. A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it.
Skip the reply kind in input-message. The generic Chat Definition would name a plugin's source kind.
Deduplicate in the grouping output. This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation.
Consequences
- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store.
- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data.
- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim.
- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation.
Testing
packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw.packages/client/ui-user-questions/tests/question-reply.client.spec.ts: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply.
中文
Problem
每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。
迟到的问题回答是已上线的实例。Chat 的通用 input-message Definition 匹配每条追加的 user/message,问题 UI 的 question-reply Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 turn-trigger 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。
Decision
match 结果可以在 Definition 的 target 上认领它的事件:
interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
readonly exclusive?: true
}
- 派发。
ConversationNodeAssembler.dispatchInput()先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。 - 失败。 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其
seq与认领方 Definition 的 kind。 - 范围。 认领只对一个事件生效,由
match()返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。 - 当前用法。
question-reply对已记录的user-question-reply消息返回exclusive: true。Chat 不再按消息 id 聚合,回答的放置不变。
Alternatives considered
Definition 优先级(specific / default)。 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。
用注册表声明真人来源,并通过用户文本规则渲染回答。 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。
在持久数据中标记真人撰写的来源。 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。
在 input-message 中跳过回答的 kind。 通用的 Chat Definition 会点名插件的来源 kind。
在分组输出层去重。 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。
Consequences
- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。
- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。
- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。
- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。
Testing
packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。packages/client/ui-user-questions/tests/question-reply.client.spec.ts:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。