test: update conversation assembly coverage and docs
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: bd8528e97b04d5b4b28922266306969e8f19295a
|
||||
README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615
|
||||
README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e
|
||||
README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4
|
||||
|
||||
@@ -34,11 +34,13 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
## The human transcript
|
||||
## Conversation assembly
|
||||
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view with the producer role and name: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the Service Definition's declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
|
||||
Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold.
|
||||
|
||||
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
|
||||
|
||||
## Request inspection
|
||||
|
||||
@@ -46,7 +48,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
|
||||
|
||||
## Code Mode child-call tree
|
||||
|
||||
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
|
||||
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract.
|
||||
|
||||
## Session title projection
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。
|
||||
|
||||
## 面向人的 transcript(文本记录)
|
||||
## Conversation 组装
|
||||
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份包含生产者角色和名称的 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在 Service Definition 的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。
|
||||
|
||||
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。
|
||||
|
||||
## 请求检查
|
||||
|
||||
@@ -46,7 +48,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## Code Mode 子调用树
|
||||
|
||||
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。
|
||||
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* fiber-scoped loop teardown.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
@@ -112,6 +114,31 @@ describe('runtime client apply', () => {
|
||||
expect(bench.api.callsOf('session.create')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
|
||||
const bench = await mount()
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r-registry' as never,
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never,
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(sessions.binding('s-registry' as never)).toBeDefined()
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'registry-probe',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
|
||||
bench.ctx.conversationEvents.register(definition)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(rebuild).toHaveBeenCalledOnce()
|
||||
rebuild.mockRestore()
|
||||
})
|
||||
|
||||
it('stops the stream loop when the plugin fiber unloads', async () => {
|
||||
const bench = await mount()
|
||||
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Behavioral half of the compaction-checkpoint drift trap.
|
||||
*
|
||||
* `TranscriptAdapter` pins its plugin literal to the Service Definition's declaration at
|
||||
* compile time through a type-only import of `dsh-compact/checkpoint`, so
|
||||
* renaming the Service Definition's plugin already fails `tsc`. This spec covers the same
|
||||
* drift from the other side — end to end through the adapter, driving it with a
|
||||
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
|
||||
* checking the Service Definition's predicate agrees. Both values come from the
|
||||
* cordis-free checkpoint leaf, so the client test program never loads the host
|
||||
* package root or its `Context` merges.
|
||||
*/
|
||||
|
||||
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
|
||||
|
||||
/** A replacement user message stamped with the Service Definition's canonical source. */
|
||||
function canonicalCheckpoint(seq: number): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}),
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
describe('compaction checkpoint recognition', () => {
|
||||
it('recognizes a checkpoint carrying the seam-canonical source', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([canonicalCheckpoint(1)])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it("agrees with the seam's own predicate on the source it recognizes", () => {
|
||||
// Both sides answer the same question about the same value: if the Service Definition
|
||||
// renames its plugin, this equality is what breaks.
|
||||
const checkpoint = canonicalCheckpoint(1)
|
||||
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
|
||||
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
|
||||
})
|
||||
})
|
||||
892
packages/client/runtime/tests/conversation-assembler.spec.ts
Normal file
892
packages/client/runtime/tests/conversation-assembler.spec.ts
Normal file
@@ -0,0 +1,892 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts'
|
||||
import type {
|
||||
ConversationEventInput, ConversationMatch, ConversationNodeContext,
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
|
||||
interface ScopeProbeStepData {
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
interface ScopeProbeTurnData {
|
||||
readonly valueSeenFromStep: number
|
||||
}
|
||||
|
||||
declare module '../src/client/contract/conversation.ts' {
|
||||
interface ConversationStepDataMap {
|
||||
'scope-probe': ScopeProbeStepData
|
||||
}
|
||||
|
||||
interface ConversationTurnDataMap {
|
||||
'scope-probe': ScopeProbeTurnData
|
||||
}
|
||||
}
|
||||
|
||||
interface TestSnapshot {
|
||||
readonly order: readonly string[]
|
||||
readonly nodes: ReadonlyMap<string, ConversationViewNode>
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
constructor(
|
||||
readonly definitions: readonly ConversationNodeDefinition[],
|
||||
readonly fallback?: ConversationNodeDefinition,
|
||||
) {}
|
||||
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return this.definitions
|
||||
}
|
||||
|
||||
fallbackEntry(): ConversationNodeDefinition | undefined {
|
||||
return this.fallback
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
|
||||
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return this.definitions
|
||||
}
|
||||
}
|
||||
|
||||
function testView(
|
||||
apply = vi.fn(),
|
||||
): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
|
||||
return {
|
||||
target: 'chat',
|
||||
create: () => {
|
||||
let current: TestSnapshot = { order: [], nodes: new Map() }
|
||||
return {
|
||||
empty: current,
|
||||
replace: ({ nodes }) => {
|
||||
current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
|
||||
return current
|
||||
},
|
||||
apply: ({ upserts }) => {
|
||||
apply(upserts)
|
||||
const nodes = new Map(current.nodes)
|
||||
const order = [...current.order]
|
||||
for (const node of upserts) {
|
||||
if (!nodes.has(node.key)) order.push(node.key)
|
||||
nodes.set(node.key, node)
|
||||
}
|
||||
current = { order, nodes }
|
||||
return current
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function at(seq: number, type: string, data: unknown): SessionEvent {
|
||||
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
|
||||
}
|
||||
|
||||
function input(event: SessionEvent): ConversationEventInput {
|
||||
return { event, view: undefined }
|
||||
}
|
||||
|
||||
function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
|
||||
return assembler.snapshot('chat') as TestSnapshot | undefined
|
||||
}
|
||||
|
||||
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
|
||||
return {
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConversationNodeAssembler', () => {
|
||||
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
|
||||
const starts = vi.fn((
|
||||
_context: ConversationNodeContext<{ callSeq: number; results: number }>,
|
||||
match: ConversationMatch,
|
||||
) => ({ callSeq: match.event.seq, results: 0 }))
|
||||
const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
|
||||
...context.state,
|
||||
results: context.state.results + 1,
|
||||
}))
|
||||
const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
|
||||
kind: 'tool',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
||||
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
|
||||
input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
starts.mockClear()
|
||||
|
||||
assembler.append(input(at(3, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
|
||||
})))
|
||||
assembler.flush()
|
||||
|
||||
expect(starts).not.toHaveBeenCalled()
|
||||
expect(updates).toHaveBeenCalledOnce()
|
||||
const snapshot = chatSnapshot(assembler)
|
||||
expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
|
||||
{ callSeq: 1, results: 1 },
|
||||
{ callSeq: 2, results: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps one Match collection while a long Context appends without replay', () => {
|
||||
const starts = vi.fn(() => 0)
|
||||
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
|
||||
context.state + 1
|
||||
))
|
||||
const matchCollections = new Set<readonly ConversationMatch[]>()
|
||||
const definition: ConversationNodeDefinition<number> = {
|
||||
kind: 'append-linear',
|
||||
match: (event) => {
|
||||
const type: string = event.type
|
||||
if (type === 'linear/start') return { id: 'one', role: 'start' }
|
||||
if (type === 'linear/update') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: (context) => {
|
||||
matchCollections.add(context.matches)
|
||||
return starts()
|
||||
},
|
||||
update: (context) => {
|
||||
matchCollections.add(context.matches)
|
||||
return updates(context)
|
||||
},
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'linear/start', {}))], false)
|
||||
starts.mockClear()
|
||||
|
||||
for (let seq = 2; seq <= 1_001; seq++) {
|
||||
assembler.append(input(at(seq, 'linear/update', {})))
|
||||
}
|
||||
assembler.flush()
|
||||
|
||||
expect(starts).not.toHaveBeenCalled()
|
||||
expect(updates).toHaveBeenCalledTimes(1_000)
|
||||
expect(matchCollections.size).toBe(1)
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
|
||||
})
|
||||
|
||||
it('merges an older page and replays its affected Context once', () => {
|
||||
const starts = vi.fn(() => 0)
|
||||
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
|
||||
context.state + 1
|
||||
))
|
||||
const definition: ConversationNodeDefinition<number> = {
|
||||
kind: 'prepend-linear',
|
||||
match: (event) => {
|
||||
const type: string = event.type
|
||||
if (type === 'linear/start') return { id: 'one', role: 'start' }
|
||||
if (type === 'linear/update') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
const current = Array.from({ length: 100 }, (_, index) => (
|
||||
input(at(index + 102, 'linear/update', {}))
|
||||
))
|
||||
assembler.replaceWindow(current, true)
|
||||
assembler.flush()
|
||||
expect(starts).not.toHaveBeenCalled()
|
||||
expect(updates).not.toHaveBeenCalled()
|
||||
|
||||
const older = [
|
||||
input(at(1, 'linear/start', {})),
|
||||
...Array.from({ length: 100 }, (_, index) => (
|
||||
input(at(index + 2, 'linear/update', {}))
|
||||
)),
|
||||
]
|
||||
assembler.prepend(older, false)
|
||||
assembler.flush()
|
||||
|
||||
expect(starts).toHaveBeenCalledOnce()
|
||||
expect(updates).toHaveBeenCalledTimes(200)
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
|
||||
})
|
||||
|
||||
it('collects an update before its start and replays it once prepend supplies the start', () => {
|
||||
const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
|
||||
const definition: ConversationNodeDefinition<{ settled: boolean }> = {
|
||||
kind: 'tool',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
||||
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => ({ settled: false }),
|
||||
update: updates,
|
||||
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(10, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
|
||||
}))], true)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
||||
.toEqual({ pendingStart: true })
|
||||
|
||||
assembler.prepend([input(at(5, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
|
||||
}))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(updates).toHaveBeenCalledOnce()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
||||
.toEqual({ settled: true })
|
||||
})
|
||||
|
||||
it('rejects a Definition whose declared start follows an update in log order', () => {
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'invalid-lifecycle',
|
||||
match: event => event.type === 'turn/end'
|
||||
? { id: 'one', role: 'start' }
|
||||
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
expect(() => assembler.replaceWindow([
|
||||
input(at(1, 'turn/start', { turn: 1 })),
|
||||
input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
|
||||
], false)).toThrow('received an update before its start Match')
|
||||
})
|
||||
|
||||
it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
|
||||
const source: ConversationNodeDefinition<number> = {
|
||||
kind: 'source',
|
||||
match: event => event.type === 'user/message'
|
||||
? { id: String(event.data.id), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
||||
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
||||
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
||||
) => reader.previous<number>('source')?.state ?? -1)
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
kind: 'consumer',
|
||||
match: event => event.type === 'assistant/message'
|
||||
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([source, consumer]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(10, 'assistant/message', {
|
||||
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
||||
}))], true)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
|
||||
|
||||
assembler.prepend([input(at(5, 'user/message', {
|
||||
id: 'm1', value: 7, content: [], source: { kind: 'user' },
|
||||
}))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(consumerStart).toHaveBeenCalledTimes(2)
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
|
||||
})
|
||||
|
||||
it('keeps the predecessor index ordered across prepend and append', () => {
|
||||
const source: ConversationNodeDefinition<number> = {
|
||||
kind: 'source',
|
||||
match: event => event.type === 'user/message'
|
||||
? { id: String(event.data.id), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
kind: 'consumer',
|
||||
match: event => event.type === 'assistant/message'
|
||||
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
: null,
|
||||
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([source, consumer]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
|
||||
input(at(50, 'assistant/message', {
|
||||
turn: 1, step: 1, message: { role: 'assistant', content: [] },
|
||||
})),
|
||||
], true)
|
||||
assembler.flush()
|
||||
|
||||
assembler.prepend([
|
||||
input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
|
||||
input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
assembler.append(input(at(60, 'user/message', {
|
||||
id: 'm60', content: [], source: { kind: 'user' },
|
||||
})))
|
||||
assembler.append(input(at(70, 'assistant/message', {
|
||||
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
||||
})))
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
||||
.toEqual([40, 60])
|
||||
})
|
||||
|
||||
it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
|
||||
const consumerStart = vi.fn((
|
||||
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
||||
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
||||
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
||||
) => reader.previous<number>('source')?.state ?? -1)
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
kind: 'consumer',
|
||||
match: event => event.type === 'assistant/message'
|
||||
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([consumer]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(10, 'assistant/message', {
|
||||
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
||||
}))], true)
|
||||
assembler.flush()
|
||||
|
||||
expect(assembler.prepend([], false)).toBe('immediate')
|
||||
assembler.flush()
|
||||
|
||||
expect(consumerStart).toHaveBeenCalledTimes(2)
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
|
||||
})
|
||||
|
||||
it('replays direct dependents when an append revises their predecessor Context', () => {
|
||||
const source: ConversationNodeDefinition<number> = {
|
||||
kind: 'source',
|
||||
match: (event) => {
|
||||
if (event.type === 'user/message') return { id: 'one', role: 'start' }
|
||||
if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
||||
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
||||
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
||||
) => reader.previous<number>('source')?.state ?? -1)
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
kind: 'consumer',
|
||||
match: event => event.type === 'assistant/message'
|
||||
? { id: 'one', role: 'start' }
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([source, consumer]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
|
||||
input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate')
|
||||
assembler.flush()
|
||||
|
||||
expect(consumerStart).toHaveBeenCalledTimes(2)
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
|
||||
})
|
||||
|
||||
it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
|
||||
const apply = vi.fn()
|
||||
const starts = vi.fn((
|
||||
_context: Parameters<ConversationNodeDefinition<string>['start']>[0],
|
||||
match: Parameters<ConversationNodeDefinition<string>['start']>[1],
|
||||
) => match.location.kind === 'step' ? match.location.step.status : 'missing')
|
||||
const definition: ConversationNodeDefinition<string> = {
|
||||
kind: 'step',
|
||||
match: event => event.type === 'step/start'
|
||||
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
: null,
|
||||
start: starts,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView(apply)]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'turn/start', { turn: 1 })),
|
||||
input(at(2, 'step/start', { turn: 1, step: 1 })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
|
||||
|
||||
assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 })))
|
||||
assembler.flush()
|
||||
|
||||
expect(starts).toHaveBeenCalledTimes(2)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
|
||||
})
|
||||
|
||||
it('lets one Context publish Step and Turn data in phase order', () => {
|
||||
interface State {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
const definition: ConversationNodeDefinition<State> = {
|
||||
kind: 'scope-probe',
|
||||
match: (event) => {
|
||||
if (event.type === 'step/start') {
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
}
|
||||
if ((event.type as string) === 'scope-probe/update') {
|
||||
return { id: '1:1', role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
|
||||
return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
|
||||
},
|
||||
update: (_context, match) => ({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
value: (match.event.data as unknown as { value: number }).value,
|
||||
}),
|
||||
buildLocationData: (context, scope) => {
|
||||
const state = context.state
|
||||
if (state === undefined) return null
|
||||
if (scope === 'step') {
|
||||
return {
|
||||
kind: 'step',
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
key: 'scope-probe',
|
||||
value: { value: state.value },
|
||||
}
|
||||
}
|
||||
const location = context.start?.location
|
||||
const stepValue = location?.kind === 'step'
|
||||
? location.step.data.get('scope-probe')?.value
|
||||
: undefined
|
||||
return {
|
||||
kind: 'turn',
|
||||
turn: state.turn,
|
||||
key: 'scope-probe',
|
||||
value: { valueSeenFromStep: stepValue ?? -1 },
|
||||
}
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
if (location?.kind !== 'step') return null
|
||||
return node(context, {
|
||||
step: location.step.data.get('scope-probe')?.value,
|
||||
turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
|
||||
})
|
||||
},
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'turn/start', { turn: 1 })),
|
||||
input(at(2, 'step/start', { turn: 1, step: 1 })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
||||
.toEqual({ step: 1, turn: 1 })
|
||||
|
||||
assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
||||
.toEqual({ step: 2, turn: 2 })
|
||||
})
|
||||
|
||||
it('updates existing turn Locations when their Step membership changes', () => {
|
||||
const apply = vi.fn()
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'turn-probe',
|
||||
match: event => event.type === 'turn/start'
|
||||
? { id: String(event.data.turn), role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
|
||||
? context.start.location.turn.steps.length
|
||||
: -1),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView(apply)]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
|
||||
|
||||
assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 })))
|
||||
assembler.flush()
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes a changed timeline even when no business Definition claims the boundary', () => {
|
||||
const apply = vi.fn()
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([]),
|
||||
new TestViewDefinitions([testView(apply)]),
|
||||
)
|
||||
assembler.replaceWindow([], false)
|
||||
assembler.flush()
|
||||
|
||||
assembler.append(input(at(1, 'turn/start', { turn: 1 })))
|
||||
assembler.flush()
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(chatSnapshot(assembler)?.order).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'location-probe',
|
||||
match: (event) => {
|
||||
if ((event.type as string) === 'command/run') {
|
||||
return {
|
||||
id: (event.data as unknown as { commandId: string }).commandId,
|
||||
role: 'start',
|
||||
}
|
||||
}
|
||||
if ((event.type as string) === 'compact/start') {
|
||||
return {
|
||||
id: (event.data as unknown as { compactionId: string }).compactionId,
|
||||
role: 'start',
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
const data = location?.kind === 'step'
|
||||
? `step:${location.turn.turn}:${location.step.step}`
|
||||
: location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
|
||||
return node(context, data)
|
||||
},
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'turn/start', { turn: 1 })),
|
||||
input(at(2, 'step/start', { turn: 1, step: 1 })),
|
||||
input(at(3, 'turn/start', { turn: 2 })),
|
||||
input(at(4, 'command/run', { commandId: 'command', name: 'x' })),
|
||||
input(at(5, 'compact/start', { compactionId: 'compact', turn: null })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
||||
.toEqual(['turn:2', 'session'])
|
||||
})
|
||||
|
||||
it('assigns turn boundaries to the Turn even when a Step remains open', () => {
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'turn-boundary-probe',
|
||||
match: event => event.type === 'turn/end'
|
||||
? { id: String(event.data.turn), role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.start?.location.kind),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'turn/start', { turn: 1 })),
|
||||
input(at(2, 'step/start', { turn: 1, step: 1 })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
|
||||
assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
|
||||
})
|
||||
|
||||
it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'location-probe',
|
||||
match: event => (event.type as string) === 'tool/code-dispatch-start'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
? `${location.turn.turn}:${location.step.step}`
|
||||
: location?.kind)
|
||||
},
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
|
||||
input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
|
||||
], true)
|
||||
assembler.flush()
|
||||
|
||||
assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
||||
.toEqual(['2:3', '2:3'])
|
||||
})
|
||||
|
||||
it('treats loaded end boundaries as closed when their starts precede the window', () => {
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'location-probe',
|
||||
match: event => event.type === 'tool/call'
|
||||
? { id: String(event.data.callId), role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
? `${location.turn.status}:${location.step.status}`
|
||||
: location?.kind)
|
||||
},
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
|
||||
input(at(11, 'step/end', { turn: 2, step: 3 })),
|
||||
input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
|
||||
], true)
|
||||
assembler.flush()
|
||||
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
||||
.toBe('closed:closed')
|
||||
})
|
||||
|
||||
it('restarts State creation from undefined when Location changes replay a Context', () => {
|
||||
const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
|
||||
expect(context.state).toBeUndefined()
|
||||
return 1
|
||||
})
|
||||
const definition: ConversationNodeDefinition<number> = {
|
||||
kind: 'replay-probe',
|
||||
match: event => event.type === 'step/start'
|
||||
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
: null,
|
||||
start: seen,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false)
|
||||
assembler.flush()
|
||||
|
||||
assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 })))
|
||||
assembler.flush()
|
||||
|
||||
expect(seen).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed',
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const fallback: ConversationNodeDefinition<string> = {
|
||||
kind: 'fallback',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: fallbackStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallback),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(fallbackStart).not.toHaveBeenCalled()
|
||||
expect(chatSnapshot(assembler)?.order).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
|
||||
const definition: ConversationNodeDefinition<boolean> = {
|
||||
kind: 'toggle',
|
||||
match: (event) => {
|
||||
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
|
||||
if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => true,
|
||||
update: () => false,
|
||||
buildViewNode: context => context.state === true ? node(context, true) : null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
|
||||
assembler.append(input(at(2, 'toggle/hide', {})))
|
||||
expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/)
|
||||
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loud when a Definition returns undefined State', () => {
|
||||
const startUndefined: ConversationNodeDefinition = {
|
||||
kind: 'undefined-start',
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: () => undefined,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const startAssembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([startUndefined]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
expect(() => startAssembler.replaceWindow([
|
||||
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
|
||||
], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
|
||||
|
||||
const updateUndefined: ConversationNodeDefinition<boolean> = {
|
||||
kind: 'undefined-update',
|
||||
match: (event) => {
|
||||
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
|
||||
if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => true,
|
||||
update: () => undefined as never,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const updateAssembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([updateUndefined]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
updateAssembler.replaceWindow([
|
||||
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
|
||||
], false)
|
||||
expect(() => updateAssembler.append(
|
||||
input(at(2, 'command/done', { commandId: 'one', kind: 'success' })),
|
||||
)).toThrow(/Definition "undefined-update" returned undefined from update/)
|
||||
})
|
||||
|
||||
it('rejects a duplicate start before mutating the existing Context', () => {
|
||||
const definition: ConversationNodeDefinition<number> = {
|
||||
kind: 'single-start',
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([definition]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(() => assembler.append(
|
||||
input(at(2, 'command/run', { commandId: 'two', name: 'x' })),
|
||||
)).toThrow(/received more than one start Match/)
|
||||
assembler.flush()
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
|
||||
})
|
||||
})
|
||||
126
packages/client/runtime/tests/conversation-registry.spec.ts
Normal file
126
packages/client/runtime/tests/conversation-registry.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
kind,
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
|
||||
return {
|
||||
target,
|
||||
create: () => ({
|
||||
empty: null,
|
||||
replace: () => null,
|
||||
apply: () => null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function bootRegistries(): Promise<{
|
||||
ctx: Context
|
||||
events: ConversationEventRegistry
|
||||
views: ConversationViewRegistry
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
||||
return { ctx, events, views }
|
||||
}
|
||||
|
||||
describe('Conversation registries', () => {
|
||||
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const definition = eventDefinition('message')
|
||||
const dispose = events.register(definition)
|
||||
|
||||
expect(events.entries()).toEqual([definition])
|
||||
expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback = eventDefinition('unknown')
|
||||
const dispose = events.registerFallback(fallback)
|
||||
|
||||
expect(events.fallbackEntry()).toBe(fallback)
|
||||
expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
||||
const { views } = await bootRegistries()
|
||||
const definition = viewDefinition('chat')
|
||||
const dispose = views.register(definition)
|
||||
|
||||
expect(views.entries()).toEqual([definition])
|
||||
expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes Event, fallback, and view contributions with their caller fiber', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => {
|
||||
featureCtx.conversationEvents.register(eventDefinition('message'))
|
||||
featureCtx.conversationEvents.registerFallback(eventDefinition('unknown'))
|
||||
featureCtx.conversationViews.register(viewDefinition('chat'))
|
||||
})
|
||||
await feature.await()
|
||||
|
||||
expect(events.entries()).toHaveLength(1)
|
||||
expect(events.fallbackEntry()).toBeDefined()
|
||||
expect(views.entries()).toHaveLength(1)
|
||||
|
||||
await feature.dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('coalesces registry changes into one rebuild of every resident Session', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const api = new FakeApiClient()
|
||||
const sessionId = 'resident' as SessionId
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
sessions.scope(sessionId)
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
|
||||
events.register(eventDefinition('message'))
|
||||
views.register(viewDefinition('chat'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(rebuild).toHaveBeenCalledOnce()
|
||||
rebuild.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -54,12 +54,12 @@ export const ev = {
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
}),
|
||||
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
|
||||
@@ -158,7 +158,6 @@ describe('queue snapshot intake', () => {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
|
||||
@@ -8,15 +8,17 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
ConversationEventInput, ConversationNode, ConversationNodeDefinition,
|
||||
ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot,
|
||||
ConversationViewDefinition,
|
||||
} from '../src/client/index.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
@@ -24,8 +26,142 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
interface TestEventState extends ConversationEventInput {}
|
||||
|
||||
class TestNodeStore implements ChatNodeStore {
|
||||
private readonly nodes = new Map<string, ChatConversationViewNode>()
|
||||
private cache: readonly ChatConversationViewNode[] = EMPTY
|
||||
|
||||
get(key: string): ChatConversationViewNode | undefined {
|
||||
return this.nodes.get(key)
|
||||
}
|
||||
|
||||
values(): readonly ChatConversationViewNode[] {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
replace(nodes: readonly ChatConversationViewNode[]): void {
|
||||
this.nodes.clear()
|
||||
for (const node of nodes) this.nodes.set(node.key, node)
|
||||
this.cache = [...this.nodes.values()]
|
||||
}
|
||||
|
||||
upsert(nodes: readonly ChatConversationViewNode[]): void {
|
||||
if (nodes.length === 0) return
|
||||
for (const node of nodes) this.nodes.set(node.key, node)
|
||||
this.cache = [...this.nodes.values()]
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_LOCATIONS: ChatLocationNodeIndex = {
|
||||
getTurn: () => EMPTY,
|
||||
getStep: () => EMPTY,
|
||||
}
|
||||
|
||||
function testLegacy(
|
||||
nodes: readonly ChatConversationViewNode[],
|
||||
timeline: ConversationTimelineSnapshot,
|
||||
): ChatSnapshot['legacy'] {
|
||||
const legacyNodes = nodes.flatMap((node): ConversationNode[] => {
|
||||
const event = (node.data as TestEventState).event
|
||||
if (event.type === 'user/message') return [{ kind: 'user', seq: event.seq } as ConversationNode]
|
||||
if (event.type === 'assistant/message') return [{ kind: 'assistant', seq: event.seq } as ConversationNode]
|
||||
return []
|
||||
})
|
||||
const turnTimings = new Map<number, { startTime: number; endTime?: number }>()
|
||||
const turnEnds = new Map<number, number>()
|
||||
for (const turn of timeline.turns.values()) {
|
||||
if (turn.start !== undefined) {
|
||||
turnTimings.set(turn.turn, turn.end === undefined
|
||||
? { startTime: turn.start.time }
|
||||
: { startTime: turn.start.time, endTime: turn.end.time })
|
||||
}
|
||||
if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq)
|
||||
}
|
||||
return { nodes: legacyNodes, turnTimings, turnEnds, partial: null, runningCalls: EMPTY }
|
||||
}
|
||||
|
||||
function testViewDefinition(): ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> {
|
||||
return {
|
||||
target: 'chat',
|
||||
create: () => {
|
||||
const store = new TestNodeStore()
|
||||
let current: ChatSnapshot = {
|
||||
order: EMPTY,
|
||||
nodes: store,
|
||||
locations: TEST_LOCATIONS,
|
||||
timeline: { turnOrder: EMPTY, turns: new Map() },
|
||||
legacy: testLegacy(EMPTY, { turnOrder: EMPTY, turns: new Map() }),
|
||||
}
|
||||
const build = (timeline: ConversationTimelineSnapshot): ChatSnapshot => {
|
||||
const nodes = [...store.values()].sort((left, right) => left.anchorSeq - right.anchorSeq)
|
||||
current = {
|
||||
order: nodes.map(node => node.key),
|
||||
nodes: store,
|
||||
locations: TEST_LOCATIONS,
|
||||
timeline,
|
||||
legacy: testLegacy(nodes, timeline),
|
||||
}
|
||||
return current
|
||||
}
|
||||
return {
|
||||
empty: current,
|
||||
replace: ({ nodes, timeline }) => {
|
||||
store.replace(nodes)
|
||||
return build(timeline)
|
||||
},
|
||||
apply: ({ upserts, timeline }) => {
|
||||
store.upsert(upserts)
|
||||
return build(timeline)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
|
||||
kind: 'runtime-test-event',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: (_context, match) => ({ event: match.event, view: match.view }),
|
||||
update: context => context.state,
|
||||
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'runtime-test-event',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
location: context.start.location,
|
||||
visibility: 'visible',
|
||||
data: context.state,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const TEST_CONVERSATION: ConversationRuntime = {
|
||||
events: {
|
||||
entries: () => [TEST_EVENT_DEFINITION],
|
||||
fallbackEntry: () => undefined,
|
||||
} as unknown as ConversationRuntime['events'],
|
||||
views: {
|
||||
entries: () => [testViewDefinition()],
|
||||
} as unknown as ConversationRuntime['views'],
|
||||
}
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) }
|
||||
}
|
||||
|
||||
function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] {
|
||||
return snapshot.chat.order.map(key => snapshot.chat.nodes.get(key)?.data as TestEventState)
|
||||
}
|
||||
|
||||
function chatSeqs(snapshot: ConversationSnapshot): number[] {
|
||||
return chatEvents(snapshot).map(item => item.event.seq)
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
@@ -34,6 +170,14 @@ function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => {
|
||||
const { session } = makeSession()
|
||||
expect(session.getSnapshot()).toMatchObject({ blank: true, composerPhase: 'blank' })
|
||||
|
||||
session.handleRunning(true)
|
||||
expect(session.getSnapshot()).toMatchObject({ blank: false, composerPhase: 'active' })
|
||||
})
|
||||
|
||||
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const page = plainTurn(10, 3, '问', '答')
|
||||
@@ -115,74 +259,28 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
|
||||
// A fresh session whose only window content is a command pair (plus the
|
||||
// knob events a /permission switch appends — not surface-eligible, so
|
||||
// they never become nodes) stays phase 'blank': selecting a preset from
|
||||
// the hero must not enter the conversation view.
|
||||
it('keeps the authoritative host blank bit across unrelated log events', async () => {
|
||||
const { session } = await opened([])
|
||||
session.handleBlank(true)
|
||||
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', 'preset danger-full-access'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(chatSeqs(snapshot)).toEqual([0, 1])
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.user(7, '流式问'))
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '半截'))
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] })
|
||||
feed(ev.chunkText(10, 1, '回复'))
|
||||
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }])
|
||||
feed(ev.assistant(11, 1, '半截回复'))
|
||||
feed(ev.turnEnd(12, 1))
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
const last = snapshot.nodes.at(-1)
|
||||
expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] })
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
const published: number[][] = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
published.push(chatSeqs(session.getSnapshot()))
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
@@ -195,356 +293,46 @@ describe('live event path', () => {
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
expect(published).toEqual([
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8],
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
expect(published).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '请重试'),
|
||||
ev.stepStart(8, 1),
|
||||
ev.chunkStart(9, 1),
|
||||
ev.chunkText(10, 1, '不完整回复'),
|
||||
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
ev.chunkStart(12, 1),
|
||||
ev.assistant(13, 1, '完整回复'),
|
||||
ev.stepEnd(14, 1),
|
||||
ev.turnEnd(15, 1),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 6)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 450,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(6)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event => event.type === 'turn/start')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
it('projects unretried terminal failures at turn/end and reproduces them from history', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
it('publishes a timeline-only boundary even when no Definition claims the event', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse([])
|
||||
const conversation: ConversationRuntime = {
|
||||
events: {
|
||||
entries: () => [],
|
||||
fallbackEntry: () => undefined,
|
||||
} as unknown as ConversationRuntime['events'],
|
||||
views: {
|
||||
entries: () => [testViewDefinition()],
|
||||
} as unknown as ConversationRuntime['views'],
|
||||
}
|
||||
const failedTurns = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '鉴权失败'),
|
||||
ev.stepStart(8, 1),
|
||||
at(9, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ev.turnStart(10, 2),
|
||||
ev.user(11, '内部失败'),
|
||||
ev.stepStart(12, 2, 1),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
}),
|
||||
]
|
||||
for (const event of failedTurns) feed(event)
|
||||
const session = new Session(SID, api, { conversation })
|
||||
await session.open()
|
||||
const snapshots: ConversationSnapshot[] = []
|
||||
session.subscribe(() => { snapshots.push(session.getSnapshot()) })
|
||||
|
||||
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
|
||||
expect(errors).toMatchObject([
|
||||
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
// Every failed turn carries a structured failure; unstructured errors
|
||||
// flatten to the UNKNOWN code.
|
||||
{ seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
|
||||
])
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes)
|
||||
})
|
||||
|
||||
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1))
|
||||
feed(ev.chunkText(8, 1, '仍在生成'))
|
||||
const valid = {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
||||
}
|
||||
const invalid = [
|
||||
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, provider: '' },
|
||||
{ ...valid, policyKey: '' },
|
||||
{ ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, delayMs: -1 },
|
||||
{ ...valid, delayMs: Number.POSITIVE_INFINITY },
|
||||
{ ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
|
||||
{ ...valid, failure: { ...valid.failure, message: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, code: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: '429' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 99 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 429.5 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 600 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: 1 } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: '' } },
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
for (const [index, data] of invalid.entries()) {
|
||||
feed(at(9 + index, { type: 'llm/retry', data }))
|
||||
}
|
||||
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
|
||||
expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
|
||||
expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts complete retry payloads at the producer field boundaries', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
session.handleMuxEnvelope('timeline' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: Number.MAX_SAFE_INTEGER,
|
||||
step: Number.MAX_SAFE_INTEGER,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
maxRetries: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: {
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'provider busy',
|
||||
status: 599,
|
||||
providerRetryAfterMs: Number.MIN_VALUE,
|
||||
requestId: 'req-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
event: ev.turnStart(0, 1),
|
||||
})
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
|
||||
})
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
feed(at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 3, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'retry forever' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
mode: 'always',
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
feed(at(7, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 4, maxRetries: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
|
||||
},
|
||||
}))
|
||||
feed(at(8, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
|
||||
retry: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unknown mode' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
})
|
||||
feed(ev.turnEnd(8, 1, reason))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'cancelled',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('marks a scheduled retry as started when its failed turn ends with an error', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
feed(at(8, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
}))
|
||||
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'started',
|
||||
})
|
||||
})
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.user(7, '要被打断的'))
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '说到一半'))
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.turnEnds.get(1)).toBe(10)
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
|
||||
// Ordered inside the flow: after the user message (seq 7), before any later turn.
|
||||
expect((frozen as { seq: number }).seq).toBeGreaterThan(7)
|
||||
})
|
||||
|
||||
it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}'))
|
||||
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }])
|
||||
feed(ev.toolResult(8, 1, 'c1', 'ECHO'))
|
||||
expect(session.getSnapshot().runningCalls).toEqual([])
|
||||
// Second call never resolves: turn/end freezes it as an error card.
|
||||
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
|
||||
feed(ev.turnEnd(10, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls).toEqual([])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps compacted history and adds one marker, live and on replay alike', async () => {
|
||||
// A landed compaction must not erase conversation the reader already saw:
|
||||
// the shadowed messages stay at their own log positions and the checkpoint
|
||||
// contributes one marker after them.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
|
||||
feed(ev.compactCheckpoint(7, 6, 1, 3))
|
||||
const live = session.getSnapshot().nodes
|
||||
expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
|
||||
expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' })
|
||||
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.compactSummary(6, '压缩摘要', 1, 3),
|
||||
ev.compactCheckpoint(7, 6, 1, 3),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes).toEqual(live)
|
||||
})
|
||||
|
||||
it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => {
|
||||
// The transcript array is seq-monotonic, so the frozen node's fractional
|
||||
// seq lands it exactly where it happened — including after a compaction
|
||||
// checkpoint whose own seq is higher than the range it shadowed.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
|
||||
feed(ev.compactCheckpoint(7, 6, 1, 3))
|
||||
feed(ev.turnStart(8, 1))
|
||||
feed(ev.user(9, '压缩后的提问'))
|
||||
feed(ev.chunkStart(10, 1))
|
||||
feed(ev.chunkText(11, 1, '说到一半'))
|
||||
feed(ev.turnEnd(12, 1, 'aborted'))
|
||||
expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([
|
||||
'user', 'assistant', 'compaction', 'user', 'assistant',
|
||||
])
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true })
|
||||
expect(snapshots).toHaveLength(1)
|
||||
expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open')
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
@@ -578,11 +366,7 @@ describe('paging', () => {
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
|
||||
})
|
||||
|
||||
it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => {
|
||||
// Pagination no longer spends maxMessages quota on replacement copies, so a
|
||||
// page can carry a compaction checkpoint whose surfaceOp.start lies outside
|
||||
// the window. The old surface fold rejected that range and degraded with a
|
||||
// console error; the log-ordered transcript has no range to resolve.
|
||||
it('installs a page without interpreting business replacement metadata', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
ev.compactSummary(80, '窗外范围的摘要', 3, 40),
|
||||
@@ -594,8 +378,7 @@ describe('paging', () => {
|
||||
await session.open()
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
|
||||
expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' })
|
||||
expect(chatSeqs(snapshot)).toEqual([80, 81, 82])
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
@@ -712,6 +495,7 @@ describe('prompt and cancel errors', () => {
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleBlank(true)
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
// flow reads the phase on the session area's first frame to keep the
|
||||
// guidance hero from flashing back in.
|
||||
@@ -730,6 +514,7 @@ describe('prompt and cancel errors', () => {
|
||||
|
||||
it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleBlank(true)
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
|
||||
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
@@ -969,33 +754,6 @@ describe('remaining branches', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
|
||||
})
|
||||
|
||||
it('turn/end sweeps only same-turn open calls; other turns keep running', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
|
||||
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
|
||||
feed(ev.turnEnd(9, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
|
||||
})
|
||||
|
||||
it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
@@ -1065,28 +823,13 @@ describe('remaining branches', () => {
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
|
||||
it('successful cancel leaves no promptError', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const result = await session.cancel()
|
||||
expect(result.ok).toBe(true)
|
||||
expect(session.getSnapshot().promptError).toBeNull()
|
||||
const callsBefore = session.getSnapshot().runningCalls
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') })
|
||||
expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference
|
||||
})
|
||||
|
||||
it('freezes a tool-call-only partial (visible through the non-text arm)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const frozen = session.getSnapshot().nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
|
||||
})
|
||||
|
||||
it('dispose is a reserved no-op on resident instances', () => {
|
||||
@@ -1094,7 +837,7 @@ describe('remaining branches', () => {
|
||||
expect(() => { session.dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => {
|
||||
it('carries history-entry and mux-frame views into the business-neutral Event input', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
@@ -1107,21 +850,23 @@ describe('remaining branches', () => {
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' },
|
||||
})
|
||||
// Live path: the frame's view slot reaches runningCalls, then the result node.
|
||||
expect(chatEvents(session.getSnapshot()).slice(-2).map(item => item.view)).toEqual([
|
||||
callView,
|
||||
{ for: 'result', view: { card: 'generic', title: '历史果' } },
|
||||
])
|
||||
session.handleMuxEnvelope('rv1' as never, {
|
||||
type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'),
|
||||
view: { for: 'call', view: { card: 'generic', title: '直播卡' } },
|
||||
} as never)
|
||||
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }])
|
||||
expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({
|
||||
for: 'call', view: { card: 'generic', title: '直播卡' },
|
||||
})
|
||||
session.handleMuxEnvelope('rv2' as never, {
|
||||
type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'),
|
||||
view: { for: 'result', view: { card: 'generic', title: '直播果' } },
|
||||
} as never)
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' },
|
||||
expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({
|
||||
for: 'result', view: { card: 'generic', title: '直播果' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1177,163 +922,27 @@ describe('resync', () => {
|
||||
|
||||
})
|
||||
|
||||
describe('nested run_code sub-dispatches', () => {
|
||||
const subCallsOf = (session: Session, callId: string) => {
|
||||
const snapshot = session.getSnapshot()
|
||||
const running = snapshot.runningCalls.find(call => call.callId === callId)
|
||||
if (running !== undefined) return running.subCalls
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
|
||||
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
|
||||
const live = subCallsOf(session, 'p1')
|
||||
expect(live).toHaveLength(2)
|
||||
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
|
||||
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
|
||||
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
|
||||
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
|
||||
const mixed = subCallsOf(session, 'p1')
|
||||
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
|
||||
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
|
||||
// The settle carries the paired start's time as callTime (duration source).
|
||||
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
|
||||
const settled = subCallsOf(session, 'p1')
|
||||
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
|
||||
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
|
||||
})
|
||||
|
||||
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
|
||||
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
|
||||
const subs = subCallsOf(session, 'p1')
|
||||
expect(subs).toHaveLength(2)
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
kind: 'tool-result', callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
|
||||
// The settle event carries no start time: callTime stays null (never a
|
||||
// fabricated zero-duration).
|
||||
callTime: null,
|
||||
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
|
||||
})
|
||||
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
|
||||
// No paired start in the window: duration is UNKNOWN (null), never a
|
||||
// fabricated zero-duration span.
|
||||
expect(subs?.[0]).toMatchObject({ callTime: null })
|
||||
// Sub-dispatches never join the surface flow.
|
||||
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds the same nested tree from a history window (replay parity)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
...plainTurn(0, 0, '问', '答'),
|
||||
ev.turnStart(6, 1),
|
||||
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
|
||||
ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
|
||||
ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
|
||||
ev.toolResult(11, 1, 'p1', '{"done":true}'),
|
||||
ev.turnEnd(12, 1),
|
||||
])
|
||||
await session.open()
|
||||
const subs = subCallsOf(session, 'p1')
|
||||
expect(subs).toHaveLength(1)
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
callId: 'p1:code:1',
|
||||
call: { name: 'run_code' },
|
||||
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unaffected root reference and path-copies it on a new child', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
|
||||
const before = session.getSnapshot()
|
||||
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '流式'))
|
||||
const after = session.getSnapshot()
|
||||
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
|
||||
expect(afterRoot).toBe(beforeRoot)
|
||||
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
|
||||
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
|
||||
expect(changedRoot).not.toBe(afterRoot)
|
||||
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
|
||||
expect(changedRoot.subCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('path-copies only the owning branch when a nested child changes', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
|
||||
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
|
||||
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
|
||||
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
|
||||
const before = session.getSnapshot()
|
||||
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
|
||||
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
|
||||
const beforeChild = beforeFirst.subCalls[0]!
|
||||
const beforeSibling = beforeFirst.subCalls[1]!
|
||||
|
||||
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
|
||||
const after = session.getSnapshot()
|
||||
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
|
||||
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
|
||||
|
||||
expect(afterFirst).not.toBe(beforeFirst)
|
||||
expect(afterSecond).toBe(beforeSecond)
|
||||
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
|
||||
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
|
||||
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
|
||||
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference stability (the memo contract)', () => {
|
||||
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
|
||||
await session.open()
|
||||
const before = session.getSnapshot()
|
||||
const firstKey = before.chat.order[0]!
|
||||
const secondKey = before.chat.order[1]!
|
||||
const first = before.chat.nodes.get(firstKey)
|
||||
const second = before.chat.nodes.get(secondKey)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') })
|
||||
const after = session.getSnapshot()
|
||||
expect(after).not.toBe(before) // top-level swap on change
|
||||
expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity
|
||||
expect(after.nodes[1]).toBe(before.nodes[1])
|
||||
expect(after.nodes).toHaveLength(3)
|
||||
expect(after.chat.nodes.get(firstKey)).toBe(first)
|
||||
expect(after.chat.nodes.get(secondKey)).toBe(second)
|
||||
expect(after.chat.order).toHaveLength(7)
|
||||
// No change → same snapshot reference.
|
||||
expect(session.getSnapshot()).toBe(after)
|
||||
})
|
||||
|
||||
it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => {
|
||||
it('keeps unrelated Session arrays and settled Chat Nodes stable across Event updates', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座'))
|
||||
await session.open()
|
||||
@@ -1343,20 +952,19 @@ describe('reference stability (the memo contract)', () => {
|
||||
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
|
||||
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
|
||||
const before = session.getSnapshot()
|
||||
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
|
||||
const settledKey = before.chat.order[0]!
|
||||
const settledNode = before.chat.nodes.get(settledKey)
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '与工具无关的流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
expect(after.chat.nodes.get(settledKey)).toBe(settledNode)
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
expect(resolved.runningCalls).not.toBe(after.runningCalls)
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode)
|
||||
feed(ev.assistant(12, 1, '完成'))
|
||||
expect(session.getSnapshot()).not.toBe(resolved)
|
||||
})
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* TranscriptAdapter over the raw append-only window: log-ordered projection of
|
||||
* append-origin events, one marker per landed compaction, replacement copies
|
||||
* hidden, command-lifecycle folding, node/array identity, call pairing, and
|
||||
* host-provided wire views.
|
||||
*/
|
||||
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
/** A `compact/summary` event (log-only, no surfaceOp). */
|
||||
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary,
|
||||
shadowedRange: { start: 1, end: 3 },
|
||||
shadowedSeqs: [1, 3],
|
||||
shadowedTokenCount: 100,
|
||||
provider: 'fake',
|
||||
model: 'compact-1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The replacement user message a compaction backend lands (the checkpoint). */
|
||||
function checkpoint(
|
||||
seq: number,
|
||||
summarySeq: number,
|
||||
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
|
||||
start?: number
|
||||
end?: number
|
||||
sourceEventSeqs?: number[]
|
||||
} = {},
|
||||
): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('TranscriptAdapter', () => {
|
||||
it('projects a window starting past seq 0 at its own log positions', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
|
||||
})
|
||||
|
||||
it('appends incrementally keeping old node references (materialize-once identity)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const first = adapter.nodes()
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second).toHaveLength(3)
|
||||
expect(second[0]).toBe(first[0])
|
||||
expect(second[1]).toBe(first[1])
|
||||
expect(second).not.toBe(first) // a real change swaps the array
|
||||
})
|
||||
|
||||
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const settled = adapter.nodes()
|
||||
adapter.append(ev.chunkStart(6, 1))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.chunkText(7, 1, '流式'))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.assistant(8, 1, '流式完成'))
|
||||
const finalized = adapter.nodes()
|
||||
expect(finalized).not.toBe(settled)
|
||||
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
|
||||
})
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 0, 'c1', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('materializes a skill-invocation injection as a named instructions context', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
|
||||
source: { kind: 'user' },
|
||||
}) }),
|
||||
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
|
||||
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
|
||||
}) }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
// The gesture stays a user bubble; the injected body folds to a context
|
||||
// row named after the skill, presented as instructions.
|
||||
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
provenance: { role: 'inject', label: 'hidden-demo' },
|
||||
form: 'instructions',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a `compact/*` record) and a future type core
|
||||
// has not admitted contribute no node.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 1),
|
||||
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
|
||||
compactSummary(2),
|
||||
ev.user(3, '唯一的一条'),
|
||||
ev.turnEnd(4, 1),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
|
||||
})
|
||||
|
||||
describe('compaction markers', () => {
|
||||
it('keeps the original messages and full tool output, hiding replacement copies', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '原始问题'),
|
||||
ev.assistant(1, 0, '原始回答'),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
|
||||
ev.toolResult(5, 0, 'c1', '完整工具输出'),
|
||||
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
|
||||
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
|
||||
} }),
|
||||
compactSummary(7),
|
||||
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
|
||||
// A regenerated assistant/message: also a silent model-only rewrite.
|
||||
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '通用 replacement 副本' }],
|
||||
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
|
||||
}),
|
||||
} }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
|
||||
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
|
||||
])
|
||||
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
|
||||
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
|
||||
})
|
||||
|
||||
it('adds one marker per landed compaction, in log order', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, 'a'),
|
||||
compactSummary(1, [{ type: 'text', text: 'first' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
ev.user(3, 'b'),
|
||||
compactSummary(4, [{ type: 'text', text: 'second' }]),
|
||||
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
|
||||
])
|
||||
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
{
|
||||
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
|
||||
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
|
||||
// The pagination hole A1 left open: quota is no longer spent on
|
||||
// replacement copies, so a page can carry a checkpoint whose
|
||||
// surfaceOp.start lies below the window head. The old surface fold threw
|
||||
// on the missing range and degraded with a console error; a log-ordered
|
||||
// projection has no range to resolve.
|
||||
const adapter = new TranscriptAdapter()
|
||||
const noise = { error: console.error, warn: console.warn }
|
||||
const logged: unknown[] = []
|
||||
console.error = (...args: unknown[]) => logged.push(args)
|
||||
console.warn = (...args: unknown[]) => logged.push(args)
|
||||
try {
|
||||
adapter.reset([
|
||||
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
|
||||
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
|
||||
ev.user(82, '压缩后的新问题'),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
|
||||
} finally {
|
||||
console.error = noise.error
|
||||
console.warn = noise.warn
|
||||
}
|
||||
expect(logged).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
|
||||
// A session-reference card carries the same plugin source shape; only the
|
||||
// replacement marker makes an event a checkpoint.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '保留'),
|
||||
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
|
||||
content: [{ type: 'text', text: '别的插件重写' }],
|
||||
source: { kind: 'plugin', plugin: 'not-compact' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['absent summary event', undefined],
|
||||
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
|
||||
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
|
||||
['an empty summary array', compactSummary(1, [])],
|
||||
['a non-array summary', compactSummary(1, 'plain string')],
|
||||
])('degrades %s to a non-expandable marker', (_label, summary) => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
...(summary === undefined ? [] : [summary]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
|
||||
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
|
||||
// so a non-text block must not discard recoverable text beside it.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the summary null when the checkpoint cites no source events', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([at(2, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('skips a cited non-summary seq before reaching the summary event', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '被压缩的问题'),
|
||||
at(1, { type: 'compact/start', data: { turn: 0 } }),
|
||||
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
|
||||
])
|
||||
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
|
||||
})
|
||||
|
||||
it('resolves the summary once an older page supplies the cited summary event', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
|
||||
adapter.reset([landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
|
||||
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
|
||||
})
|
||||
|
||||
it('creates the marker on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
|
||||
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
|
||||
const nodes = adapter.nodes()
|
||||
// The compacted history is still there; the marker is one more row after it.
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
|
||||
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns call:null for a tool-result whose call fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }),
|
||||
error: { name: 'Boom', code: 'boom' },
|
||||
} }),
|
||||
])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('attaches wire views to the materialized result node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
|
||||
adapter.reset([
|
||||
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
|
||||
ev.toolResult(1, 1, 'c1', 'listing'),
|
||||
], [callView, resultView] as never)
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches views on the live append path and defaults to null without views', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
|
||||
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
|
||||
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { title: '回声' }, resultView: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' },
|
||||
})
|
||||
})
|
||||
|
||||
describe('command lifecycle nodes', () => {
|
||||
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 0, '然后回答'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
|
||||
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a run with no done as still executing (outcome null)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
|
||||
})
|
||||
|
||||
it('represents command input omitted by the host as null', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'command', name: 'feedback', args: null, outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
|
||||
outcome: { kind: 'error', text: '失败了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'))
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.nodes().find(n => n.kind === 'command')
|
||||
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
|
||||
// Settlement replaced the node object rather than mutating the published one.
|
||||
expect(settled).not.toBe(running)
|
||||
})
|
||||
|
||||
it('tails command nodes whose seq is past every transcript node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
|
||||
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '压缩前的问题'),
|
||||
ev.commandRun(1, 'cmd-compact', 'compact'),
|
||||
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
|
||||
})
|
||||
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user