test: update conversation assembly coverage and docs
This commit is contained in:
@@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
rootCallId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
|
||||
@@ -96,7 +96,7 @@ function sgr(code: number, body: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* Terminal output sample for fixture turn 66, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
@@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 66): matches grouped by
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
@@ -191,7 +191,7 @@ const SEARCH_MATCHES_TEXT = [
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Structured glob result for the search sample (turn 67): a flat path list,
|
||||
* Structured glob result for the search sample (turn 68): a flat path list,
|
||||
* truncated with a larger `total` so the path card shows its capped indicator.
|
||||
*/
|
||||
const SEARCH_PATHS_FIXTURE = [
|
||||
@@ -426,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
|
||||
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
|
||||
// the presenter reads to emit the two-hunk sample: the card draws one path
|
||||
// header, the first hunk, a `⋯` gap, then the second (the same-file
|
||||
// second-hunk arm turns 62/63 cannot reach).
|
||||
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const turn = 65
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
|
||||
@@ -456,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
@@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
@@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
@@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
|
||||
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
|
||||
// truncated). Both ride the keyed SearchRow registration under their own
|
||||
// names; the render-site fallback row is covered by the model derivation
|
||||
// tests, since every fixture search tool has a keyed row. Ordered before the
|
||||
// todo turn for the same standing-plan reason the bash turn is.
|
||||
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
|
||||
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
|
||||
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
|
||||
// The render-site fallback ROW SHAPE (a read call on the generic flattened
|
||||
// path) is covered by the turn 64 run_code read sub-dispatches, which
|
||||
// path) is covered by the turn 65 run_code read sub-dispatches, which
|
||||
// session.ts folds with resultView: null; the fallback-row + read-CARD
|
||||
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
|
||||
// this fixture. The read render intent is result-side only, so its pending
|
||||
// call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window.
|
||||
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
|
||||
// Turns 69-70: the web render intent — a web_search whose result view carries
|
||||
// Turns 70-71: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 65 is: the standing plan retires at
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
|
||||
case 'edit':
|
||||
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
|
||||
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
|
||||
// scattered hunks share one path header and the card draws the `⋯` gap.
|
||||
if (str(args.file_path) === 'src/config.ts') {
|
||||
return {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Fixture overrides for the session behavior face: any subset of the
|
||||
@@ -45,6 +46,7 @@ export interface SessionFixture {
|
||||
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [],
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
|
||||
@@ -22,7 +22,9 @@ import { act, render, within } from '@testing-library/react'
|
||||
import type { RenderResult } from '@testing-library/react'
|
||||
import type { queries } from '@testing-library/dom'
|
||||
import type { BoundFunctions } from '@testing-library/dom'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
|
||||
@@ -142,7 +144,7 @@ export class TestRoot {
|
||||
*/
|
||||
async declare<const D extends ChildrenDecl>(
|
||||
children: D,
|
||||
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
frame: SlotComponent<ComposedProps<'root', never, keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
): Promise<void> {
|
||||
await this.stabilize(() => {
|
||||
// Erased hop (same pattern as SlotsService's own implementation arm);
|
||||
@@ -218,6 +220,8 @@ export class SlotTestRuntime {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
|
||||
it('registers the chat view and its keyed business-node seat', async () => {
|
||||
const b = await bench()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
@@ -62,7 +62,7 @@ describe('apply wiring', () => {
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('apply wiring', () => {
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call')
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
@@ -108,8 +108,8 @@ describe('apply wiring', () => {
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
expect(b.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
|
||||
expect(b.slots.entries('conversation.chat.node')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.node')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
|
||||
@@ -10,13 +10,21 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
|
||||
UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
@@ -33,7 +41,44 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
|
||||
const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
|
||||
|
||||
interface MessageItemProps {
|
||||
readonly node: ConversationNode
|
||||
readonly t: ChatNodeViewProps['t']
|
||||
}
|
||||
|
||||
/** Legacy-node fixture adapter for the independently registered renderers. */
|
||||
function MessageItem({ node, t: translate }: MessageItemProps) {
|
||||
const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind
|
||||
const viewNode: ChatConversationViewNode = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
kind,
|
||||
id: String(node.seq),
|
||||
target: 'chat',
|
||||
anchorSeq: node.seq,
|
||||
location: { kind: 'session' },
|
||||
visibility: 'visible',
|
||||
data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node,
|
||||
}
|
||||
const props = { node: viewNode, t: translate } as ChatNodeViewProps
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return <UserMessageNodeView {...props as ChatNodeViewProps<'user' | 'steering'>} />
|
||||
case 'context':
|
||||
return <ContextMessageNodeView {...props as ChatNodeViewProps<'context'>} />
|
||||
case 'compaction':
|
||||
return <CompactionNodeView {...props as ChatNodeViewProps<'compaction'>} />
|
||||
case 'model-retry':
|
||||
return <RetryNodeView {...props as ChatNodeViewProps<'model-retry'>} />
|
||||
case 'unknown':
|
||||
return <UnknownNodeView {...props as ChatNodeViewProps<'unknown'>} />
|
||||
default:
|
||||
throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
@@ -727,9 +772,9 @@ describe('MessageItem arms', () => {
|
||||
const view = render(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
@@ -761,9 +806,9 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'scheduled',
|
||||
@@ -788,6 +833,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
@@ -809,6 +855,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 7,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
@@ -828,6 +875,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 8,
|
||||
time: 12_100,
|
||||
retryState: 'cancelled',
|
||||
@@ -846,32 +894,6 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
|
||||
})
|
||||
|
||||
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
const node = {
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 5_000,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
} as const
|
||||
const view = render(<MessageItem t={t} node={node} />)
|
||||
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4_200) })
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
@@ -932,84 +954,13 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
seq={3}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(3)
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(thinkOnly.queryByText('14:24')).toBeNull()
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
|
||||
streaming={false}
|
||||
time={1_000}
|
||||
seq={1}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
}
|
||||
const nodes = [{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 },
|
||||
}] as const
|
||||
const snap = { chat: chatSnapshotFixture({ nodes }), nodes }
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
|
||||
295
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal file
295
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type {
|
||||
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore,
|
||||
ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall,
|
||||
ToolCallBlock, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function nodeSource(node: ChatConversationViewNode): unknown {
|
||||
if (node.kind === 'assistant-step') {
|
||||
const data = node.data as ReturnType<typeof assistantData>
|
||||
return data.finalNode ?? data.blocks
|
||||
}
|
||||
if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root
|
||||
if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current
|
||||
if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq
|
||||
return node.data
|
||||
}
|
||||
|
||||
class FixtureNodeStore implements ChatNodeStore {
|
||||
private byKey = new Map<string, ChatConversationViewNode>()
|
||||
private list: readonly ChatConversationViewNode[] = EMPTY
|
||||
|
||||
get(key: string): ChatConversationViewNode | undefined {
|
||||
return this.byKey.get(key)
|
||||
}
|
||||
|
||||
values(): readonly ChatConversationViewNode[] {
|
||||
return this.list
|
||||
}
|
||||
|
||||
replace(candidates: readonly ChatConversationViewNode[]): void {
|
||||
const next = new Map<string, ChatConversationViewNode>()
|
||||
const list = candidates.map((candidate) => {
|
||||
const previous = this.byKey.get(candidate.key)
|
||||
const node = previous !== undefined
|
||||
&& previous.kind === candidate.kind
|
||||
&& previous.anchorSeq === candidate.anchorSeq
|
||||
&& previous.visibility === candidate.visibility
|
||||
&& nodeSource(previous) === nodeSource(candidate)
|
||||
? previous
|
||||
: candidate
|
||||
next.set(node.key, node)
|
||||
return node
|
||||
})
|
||||
this.byKey = next
|
||||
this.list = sameValues(this.list, list) ? this.list : list
|
||||
}
|
||||
}
|
||||
|
||||
class FixtureLocationIndex implements ChatLocationNodeIndex {
|
||||
private turns = new Map<number, readonly string[]>()
|
||||
|
||||
getTurn(turn: number): readonly string[] {
|
||||
return this.turns.get(turn) ?? EMPTY
|
||||
}
|
||||
|
||||
getStep(): readonly string[] {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
replace(next: ReadonlyMap<number, readonly string[]>): void {
|
||||
const stable = new Map<number, readonly string[]>()
|
||||
for (const [turn, keys] of next) {
|
||||
const previous = this.turns.get(turn) ?? EMPTY
|
||||
stable.set(turn, sameValues(previous, keys) ? previous : keys)
|
||||
}
|
||||
this.turns = stable
|
||||
}
|
||||
}
|
||||
|
||||
class FixtureTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
|
||||
get<Key extends keyof ConversationTurnDataMap & string>(
|
||||
key: Key,
|
||||
): Readonly<ConversationTurnDataMap[Key]> | undefined {
|
||||
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
}
|
||||
|
||||
set<Key extends keyof ConversationTurnDataMap & string>(key: Key, value: ConversationTurnDataMap[Key]): void {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
function assistantData(node: AssistantMessageNode) {
|
||||
return {
|
||||
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
|
||||
turn: node.turn,
|
||||
step: node.step,
|
||||
blocks: node.blocks,
|
||||
time: node.time,
|
||||
finalNode: node,
|
||||
}
|
||||
}
|
||||
|
||||
function settledNode(
|
||||
node: ConversationNode,
|
||||
turns: ReadonlyMap<number, TurnLocation>,
|
||||
): ChatConversationViewNode {
|
||||
const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined
|
||||
const base = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
id: String(node.seq),
|
||||
target: 'chat' as const,
|
||||
anchorSeq: node.seq,
|
||||
location: turn === undefined
|
||||
? { kind: 'session' as const }
|
||||
: { kind: 'turn' as const, turn },
|
||||
visibility: 'visible' as const,
|
||||
}
|
||||
switch (node.kind) {
|
||||
case 'assistant':
|
||||
return { ...base, kind: 'assistant-step', data: assistantData(node) }
|
||||
case 'tool-result':
|
||||
return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } }
|
||||
case 'model-retry':
|
||||
return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } }
|
||||
default:
|
||||
return { ...base, kind: node.kind, data: node }
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the canonical Chat fixture corresponding to one legacy test slice. */
|
||||
export function chatSnapshotFixture(input: {
|
||||
readonly nodes?: readonly ConversationNode[]
|
||||
readonly partial?: PartialAssistant | null
|
||||
readonly runningCalls?: readonly RunningToolCall[]
|
||||
readonly turnTimings?: LegacyConversationSlice['turnTimings']
|
||||
readonly turnEnds?: LegacyConversationSlice['turnEnds']
|
||||
} = {}, previous?: ChatSnapshot): ChatSnapshot {
|
||||
const legacy: LegacyConversationSlice = {
|
||||
nodes: input.nodes ?? EMPTY,
|
||||
partial: input.partial ?? null,
|
||||
runningCalls: input.runningCalls ?? EMPTY,
|
||||
turnTimings: input.turnTimings ?? new Map(),
|
||||
turnEnds: input.turnEnds ?? new Map(),
|
||||
}
|
||||
const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()])
|
||||
for (const node of legacy.nodes) {
|
||||
if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn)
|
||||
}
|
||||
if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn)
|
||||
for (const call of legacy.runningCalls) turnNumbers.add(call.turn)
|
||||
const turns = new Map<number, TurnLocation>()
|
||||
const turnData = new Map<number, FixtureTurnDataStore>()
|
||||
for (const turn of [...turnNumbers].sort((left, right) => left - right)) {
|
||||
const timing = legacy.turnTimings.get(turn)
|
||||
const endSeq = legacy.turnEnds.get(turn)
|
||||
const data = new FixtureTurnDataStore()
|
||||
turnData.set(turn, data)
|
||||
turns.set(turn, {
|
||||
turn,
|
||||
start: timing === undefined ? undefined : {
|
||||
type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn,
|
||||
} as never,
|
||||
end: timing?.endTime === undefined || endSeq === undefined ? undefined : {
|
||||
type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed',
|
||||
} as never,
|
||||
status: endSeq === undefined ? 'open' : 'closed',
|
||||
steps: EMPTY,
|
||||
data,
|
||||
})
|
||||
}
|
||||
const linkedCompactions = new Set<CompactionSummaryNode>()
|
||||
const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => {
|
||||
if (node.kind === 'command' && node.name === 'compact') {
|
||||
const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined
|
||||
const candidates = sourceSeq === undefined
|
||||
? []
|
||||
: legacy.nodes.filter((candidate): candidate is CompactionSummaryNode =>
|
||||
candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq)
|
||||
const compaction = candidates.length === 1 ? candidates[0] : undefined
|
||||
if (node.outcome === null || compaction !== undefined) {
|
||||
if (compaction !== undefined) linkedCompactions.add(compaction)
|
||||
const base = settledNode(node, turns)
|
||||
return [{
|
||||
...base,
|
||||
key: `fixture:manual-compaction:${node.commandId}`,
|
||||
kind: 'manual-compaction',
|
||||
anchorSeq: compaction?.seq ?? node.seq,
|
||||
data: { command: node, compaction: compaction ?? null },
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (node.kind === 'compaction' && linkedCompactions.has(node)) return []
|
||||
return [settledNode(node, turns)]
|
||||
})
|
||||
if (legacy.partial !== null) {
|
||||
const turn = turns.get(legacy.partial.turn)
|
||||
nodes.push({
|
||||
key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`,
|
||||
id: `${legacy.partial.turn}:${legacy.partial.step}`,
|
||||
target: 'chat',
|
||||
kind: 'assistant-step',
|
||||
anchorSeq: Number.MAX_SAFE_INTEGER - 1,
|
||||
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: {
|
||||
status: 'running',
|
||||
turn: legacy.partial.turn,
|
||||
step: legacy.partial.step,
|
||||
blocks: legacy.partial.blocks,
|
||||
time: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const call of legacy.runningCalls) {
|
||||
const turn = turns.get(call.turn)
|
||||
nodes.push({
|
||||
key: `fixture:tool:${call.callId}`,
|
||||
id: call.callId,
|
||||
target: 'chat',
|
||||
kind: 'tool-call',
|
||||
anchorSeq: Number.MAX_SAFE_INTEGER,
|
||||
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: { root: call },
|
||||
})
|
||||
}
|
||||
for (const [turnNumber, endSeq] of legacy.turnEnds) {
|
||||
const turn = turns.get(turnNumber)
|
||||
const dataStore = turnData.get(turnNumber)
|
||||
if (turn === undefined || dataStore === undefined) continue
|
||||
const closing = legacy.nodes
|
||||
.filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant'
|
||||
&& candidate.turn === turnNumber
|
||||
&& candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== ''))
|
||||
.map(assistantData)
|
||||
.at(-1) ?? null
|
||||
const preceding = nodes.findLast((candidate) => {
|
||||
const location = candidate.location
|
||||
return (location.kind === 'turn' || location.kind === 'step')
|
||||
&& location.turn.turn === turnNumber
|
||||
})
|
||||
const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
|
||||
const tailData = {
|
||||
turn: turnNumber,
|
||||
seq: endSeq,
|
||||
time: turn.end?.time ?? 0,
|
||||
closing,
|
||||
branchUnavailable: closing === null
|
||||
|| preceding?.kind !== 'assistant-step'
|
||||
|| (preceding.data as ReturnType<typeof assistantData>).finalNode.seq !== closing.finalNode.seq,
|
||||
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
|
||||
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
|
||||
}
|
||||
dataStore.set('turn-tail', tailData)
|
||||
nodes.push({
|
||||
key: `fixture:turn-tail:${turnNumber}`,
|
||||
id: String(turnNumber),
|
||||
target: 'chat',
|
||||
kind: 'turn-tail',
|
||||
anchorSeq: endSeq,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: tailData,
|
||||
})
|
||||
}
|
||||
const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore()
|
||||
store.replace(nodes)
|
||||
const byKey = new Map(store.values().map(node => [node.key, node]))
|
||||
const nextOrder = nodes.map(node => node.key)
|
||||
const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder
|
||||
const byTurn = new Map<number, readonly string[]>()
|
||||
for (const turn of turns.keys()) {
|
||||
byTurn.set(turn, order.filter((key) => {
|
||||
const location = byKey.get(key)?.location
|
||||
return location?.kind === 'turn' && location.turn.turn === turn
|
||||
|| location?.kind === 'step' && location.turn.turn === turn
|
||||
}))
|
||||
}
|
||||
const locations = previous?.locations instanceof FixtureLocationIndex
|
||||
? previous.locations
|
||||
: new FixtureLocationIndex()
|
||||
locations.replace(byTurn)
|
||||
const timeline = previous !== undefined
|
||||
&& previous.legacy.turnTimings === legacy.turnTimings
|
||||
&& previous.legacy.turnEnds === legacy.turnEnds
|
||||
? previous.timeline
|
||||
: { turnOrder: [...turns.keys()], turns }
|
||||
return {
|
||||
order,
|
||||
nodes: store,
|
||||
locations,
|
||||
timeline,
|
||||
legacy,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -42,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: chatSnapshotFixture(),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture({
|
||||
nodes: initial.nodes,
|
||||
partial: initial.partial,
|
||||
runningCalls: initial.runningCalls,
|
||||
turnTimings: initial.turnTimings,
|
||||
turnEnds: initial.turnEnds,
|
||||
}),
|
||||
}
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({
|
||||
nodes: merged.nodes,
|
||||
partial: merged.partial,
|
||||
runningCalls: merged.runningCalls,
|
||||
turnTimings: merged.turnTimings,
|
||||
turnEnds: merged.turnEnds,
|
||||
})),
|
||||
}
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
|
||||
@@ -4,24 +4,33 @@
|
||||
// ObservableSnapshot fake, no wire or Tool presentation plugin.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -34,10 +43,11 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot {
|
||||
|
||||
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture(initial),
|
||||
}
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
|
||||
? next.chat
|
||||
: chatSnapshotFixture(merged, snap.chat),
|
||||
}
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
@@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode =>
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const retry = (seq: number): ModelRetryNode => ({
|
||||
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
|
||||
seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
retryState: 'scheduled',
|
||||
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 450,
|
||||
@@ -139,25 +160,97 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
const chat = createChatStore().create()
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const toolOwners: ToolTreeOwnerProps[] = []
|
||||
const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
if (key !== 'conversation.chat.tool') return opts?.fallback ?? null
|
||||
const tool = owner as ToolTreeOwnerProps
|
||||
toolOwners.push(tool)
|
||||
// Tool providers own their subtree. The host double carries only the
|
||||
// semantic anchor required by ChatView's prepend-position contract.
|
||||
return (
|
||||
<div
|
||||
data-testid={`tool-seat-${tool.callId}`}
|
||||
data-chat-anchor-key={`call:${tool.callId}`}
|
||||
data-chat-call-id={tool.callId}
|
||||
>
|
||||
{tool.toolName || '(unnamed)'}:{tool.callId}
|
||||
</div>
|
||||
const toolOwners: Array<{
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolCallBlock
|
||||
selectedCallId: string | undefined
|
||||
openFile: ChatNodeOwnerProps['openFile']
|
||||
inspectCall: ChatNodeOwnerProps['inspectCall']
|
||||
}> = []
|
||||
const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
|
||||
const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
|
||||
const renderTurnTailSlot = (() => null) as unknown as
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
|
||||
const renderSlot = ((key: string, owner: object, opts?: {
|
||||
fallback?: React.ReactNode
|
||||
hookContext?: unknown
|
||||
}) => {
|
||||
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
|
||||
const nodeOwner = owner as RoutedChatNodeOwner
|
||||
const nodeKey = opts?.hookContext as string | undefined
|
||||
const useTurnData = ((dataKey: string) => props.useSession((snapshot) => {
|
||||
const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(dataKey as never)
|
||||
: undefined
|
||||
})) as UseChatNodeTurnData
|
||||
const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
|
||||
{ ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
|
||||
)
|
||||
switch (nodeOwner.node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
|
||||
case 'context':
|
||||
return <ContextMessageNodeView {...nodeProps<'context'>()} />
|
||||
case 'assistant-step':
|
||||
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
|
||||
case 'command':
|
||||
return (
|
||||
<CommandNodeView
|
||||
{...nodeProps<'command'>()}
|
||||
renderSlot={renderCommandSlot}
|
||||
SessionProvider={props.SessionProvider}
|
||||
/>
|
||||
)
|
||||
case 'manual-compaction':
|
||||
return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
|
||||
case 'compaction':
|
||||
return <CompactionNodeView {...nodeProps<'compaction'>()} />
|
||||
case 'model-retry':
|
||||
return <RetryNodeView {...nodeProps<'model-retry'>()} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
|
||||
case 'turn-tail':
|
||||
return (
|
||||
<TurnTailNodeView
|
||||
{...nodeProps<'turn-tail'>()}
|
||||
renderSlot={renderTurnTailSlot}
|
||||
renderSlotChain={renderTurnTail}
|
||||
SessionProvider={props.SessionProvider}
|
||||
/>
|
||||
)
|
||||
case 'unknown':
|
||||
return <UnknownNodeView {...nodeProps<'unknown'>()} />
|
||||
case 'tool-call': {
|
||||
const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root
|
||||
const toolName = 'kind' in block ? block.call?.name ?? '' : block.name
|
||||
const tool = {
|
||||
callId: block.callId,
|
||||
toolName,
|
||||
block,
|
||||
selectedCallId: nodeOwner.selectedCallId,
|
||||
openFile: nodeOwner.openFile,
|
||||
inspectCall: nodeOwner.inspectCall,
|
||||
}
|
||||
toolOwners.push(tool)
|
||||
return (
|
||||
<div
|
||||
data-testid={`tool-seat-${tool.callId}`}
|
||||
data-chat-anchor-key={`call:${tool.callId}`}
|
||||
data-chat-call-id={tool.callId}
|
||||
>
|
||||
{tool.toolName || '(unnamed)'}:{tool.callId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return opts?.fallback ?? null
|
||||
}
|
||||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
@@ -172,7 +265,6 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
@@ -218,139 +310,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
it('groups consecutive tool results and keeps stable keys', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
const items = deriveChatFlow(nodes)
|
||||
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
const group = items[2]!
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
|
||||
it('reuses one stable row for consecutive retry turns', () => {
|
||||
const first = retry(2)
|
||||
const second = { ...retry(3), turn: 2, retry: 2 }
|
||||
const initial = deriveChatFlow([user(1, 'try'), first])
|
||||
const updated = deriveChatFlow([user(1, 'try'), first, second])
|
||||
expect(flowKeys(initial)).toBe('n1|n2')
|
||||
expect(flowKeys(updated)).toBe('n1|n2')
|
||||
expect(updated).toHaveLength(2)
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => {
|
||||
const running = command({
|
||||
seq: 1,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact')
|
||||
|
||||
const settled = {
|
||||
...running,
|
||||
outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 },
|
||||
}
|
||||
const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 })
|
||||
const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint])
|
||||
expect(flowKeys(items)).toBe('n0|n2|ccmd-compact')
|
||||
expect(items.at(-1)).toEqual({
|
||||
kind: 'command-compaction',
|
||||
key: 'ccmd-compact',
|
||||
command: settled,
|
||||
compaction: checkpoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not split adjacent tool results around a folded /compact command', () => {
|
||||
const folded = command({
|
||||
seq: 2,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 4 },
|
||||
})
|
||||
const items = deriveChatFlow([
|
||||
toolResult(1, 'a'),
|
||||
folded,
|
||||
toolResult(3, 'b'),
|
||||
compaction({ seq: 5, summaryEventSeq: 4 }),
|
||||
])
|
||||
expect(flowKeys(items)).toBe('g1|ccmd-compact')
|
||||
expect(
|
||||
items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId),
|
||||
).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => {
|
||||
const automatic = compaction({ seq: 2, summaryEventSeq: 1 })
|
||||
expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2')
|
||||
|
||||
const first = command({
|
||||
seq: 3,
|
||||
commandId: 'cmd-a' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const second = command({
|
||||
seq: 4,
|
||||
commandId: 'cmd-b' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 })
|
||||
expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10')
|
||||
|
||||
const sole = command({
|
||||
seq: 11,
|
||||
commandId: 'cmd-sole' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 12 },
|
||||
})
|
||||
const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 })
|
||||
const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 })
|
||||
expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14')
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
const headsOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
|
||||
}
|
||||
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
|
||||
expect(flowKeys(items)).toBe('g3')
|
||||
const group = items[0]!
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
// Interrupted and visible-content nodes still render (已停止 marker / prose).
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
toolResult(4, 'a'),
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
]
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
// Turn 2 is still producing steps: its latest narration owns nothing, and
|
||||
// the settled turn 1 keeps its seat.
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
describe('Chat node rendering', () => {
|
||||
|
||||
it('threads the injected file-mention vocabulary into the closing prose only', () => {
|
||||
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
@@ -391,17 +351,6 @@ describe('chat-flow derivation', () => {
|
||||
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
@@ -410,24 +359,6 @@ describe('chat-flow derivation', () => {
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'answer before tools'),
|
||||
toolResult(3, 'a'),
|
||||
interruptedThink,
|
||||
user(6, 'second'),
|
||||
assistant(7, 'clean tail', 2),
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -444,8 +375,8 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
|
||||
let firstTop = 100
|
||||
let nextTop = 300
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
|
||||
@@ -472,7 +403,7 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
|
||||
})
|
||||
|
||||
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
|
||||
it('renders the fixture main line as independently keyed business nodes', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
|
||||
})
|
||||
@@ -485,14 +416,18 @@ describe('ChatView', () => {
|
||||
key: row.getAttribute('data-chat-flow-key'),
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
}))).toEqual([
|
||||
{ key: 'n1', kind: 'user' },
|
||||
{ key: 'n2', kind: 'assistant' },
|
||||
{ key: 'g3', kind: 'tool-group' },
|
||||
{ key: 'fixture:user:1', kind: 'user' },
|
||||
{ key: 'fixture:assistant:2', kind: 'assistant-step' },
|
||||
{ key: 'fixture:tool:a', kind: 'tool-call' },
|
||||
{ key: 'fixture:tool:b', kind: 'tool-call' },
|
||||
])
|
||||
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
|
||||
.toEqual(['a', 'b'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
|
||||
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
|
||||
.toEqual([
|
||||
'fixture:user:1', 'fixture:assistant:2',
|
||||
'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
|
||||
@@ -559,14 +494,13 @@ describe('ChatView', () => {
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The completed turn's transcript tail is the steering bubble, not the
|
||||
// narration, so the assistant's branch action stays unavailable and the
|
||||
// steering bubble still offers none.
|
||||
// The Turn Tail belongs to the closed Turn, independently of a later
|
||||
// steering bubble's placement in the Chat list.
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(1)
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
fireEvent.click(branchButtons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
expect(h.forkAt).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
@@ -607,7 +541,7 @@ describe('ChatView', () => {
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
||||
h.set({ nodes: [user(1, 'try'), nextRetry] })
|
||||
})
|
||||
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
|
||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||
@@ -617,7 +551,6 @@ describe('ChatView', () => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, 'try'),
|
||||
retryNode,
|
||||
{ ...nextRetry, retryState: 'started' },
|
||||
context,
|
||||
assistant(5, 'done'),
|
||||
@@ -760,7 +693,7 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
// The user row and the settled assistant's Turn Tail each own one clock scope.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -787,7 +720,7 @@ describe('ChatView', () => {
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
|
||||
it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
@@ -843,19 +776,13 @@ describe('ChatView', () => {
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('streaming partial frames re-render only the tail (Profiler count)', () => {
|
||||
it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
|
||||
})
|
||||
let renders = 0
|
||||
const counting = (
|
||||
<Profiler id="chat" onRender={() => { renders += 1 }}>
|
||||
<h.ChatView {...h.props} />
|
||||
</Profiler>
|
||||
)
|
||||
const view = render(counting)
|
||||
const before = renders
|
||||
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const tool = view.getByTestId('tool-seat-a')
|
||||
const beforeHtml = tool.innerHTML
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
||||
})
|
||||
@@ -863,9 +790,8 @@ describe('ChatView', () => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
||||
})
|
||||
expect(view.getByText('streaming… more')).toBeTruthy()
|
||||
// Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
|
||||
expect(renders - before).toBe(2)
|
||||
expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
|
||||
expect(view.getByTestId('tool-seat-a')).toBe(tool)
|
||||
expect(tool.innerHTML).toBe(beforeHtml)
|
||||
})
|
||||
|
||||
it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
|
||||
@@ -875,8 +801,9 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = ((key: string, _owner: object) => {
|
||||
if (key !== 'conversation.chat.tool') return null
|
||||
h.props.renderSlot = ((key: string, owner: object) => {
|
||||
if (key !== 'conversation.chat.node'
|
||||
|| (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
@@ -908,6 +835,54 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
|
||||
const mounted = vi.fn()
|
||||
const unmounted = vi.fn()
|
||||
function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) {
|
||||
useEffect(() => {
|
||||
mounted()
|
||||
return () => { unmounted() }
|
||||
}, [])
|
||||
const root = (node.data as { readonly root: ToolCallBlock }).root
|
||||
return (
|
||||
<div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
|
||||
{root.callId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(4, 'later')],
|
||||
runningCalls: [runningCall('r1')],
|
||||
running: true,
|
||||
})
|
||||
h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
const routed = owner as RoutedChatNodeOwner
|
||||
return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
|
||||
? <StatefulToolNode node={routed.node} />
|
||||
: opts?.fallback ?? null
|
||||
}) as ChatViewSlotProps['renderSlot']
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const tool = view.getByTestId('stateful-tool')
|
||||
const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
|
||||
expect(tool.dataset.state).toBe('running')
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
|
||||
runningCalls: [],
|
||||
running: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(view.getByTestId('stateful-tool')).toBe(tool)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row)
|
||||
expect(tool.dataset.state).toBe('settled')
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
expect(unmounted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
@@ -932,7 +907,7 @@ describe('ChatView', () => {
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('hands each ordered root call to the whole-Tool slot', () => {
|
||||
it('hands each ordered root call to the keyed business-node slot', () => {
|
||||
const block = toolResult(3, 'a')
|
||||
const h = makeHarness({ nodes: [block] })
|
||||
const calls: { key: string; owner: object; entryKey?: string }[] = []
|
||||
@@ -943,14 +918,14 @@ describe('ChatView', () => {
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
key: 'conversation.chat.tool',
|
||||
owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined },
|
||||
key: 'conversation.chat.node',
|
||||
owner: { node: { kind: 'tool-call' }, selectedCallId: undefined },
|
||||
entryKey: 'tool-call',
|
||||
})
|
||||
const owner = calls[0]?.owner as ToolTreeOwnerProps
|
||||
expect(owner.block).toBe(block)
|
||||
const owner = calls[0]?.owner as RoutedChatNodeOwner
|
||||
expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
|
||||
expect(owner.openFile).toBe(h.openFile)
|
||||
expect(owner.inspectCall).toBe(h.inspectCall)
|
||||
expect(calls[0]?.entryKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
@@ -960,7 +935,7 @@ describe('ChatView', () => {
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement
|
||||
let anchoredTop = 100
|
||||
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
@@ -977,60 +952,6 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'call:late') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
|
||||
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:5') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -1177,7 +1098,7 @@ describe('ChatView', () => {
|
||||
() => ({ top: 0, bottom: 500 } as DOMRect),
|
||||
)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') {
|
||||
if (this.dataset.chatAnchorKey === 'fixture:user:1') {
|
||||
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 40 } as DOMRect
|
||||
@@ -1216,12 +1137,12 @@ describe('ChatView', () => {
|
||||
})
|
||||
document.body.appendChild(host)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
return { top: 0, bottom: 500 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
||||
ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
|
||||
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
|
||||
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
|
||||
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
|
||||
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
|
||||
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
|
||||
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
|
||||
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
|
||||
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
|
||||
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
|
||||
import type {
|
||||
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
|
||||
} from '../src/client/contract/chat-nodes.ts'
|
||||
|
||||
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
nextTurnInboxDefinition,
|
||||
nextStepInboxDefinition,
|
||||
messageDefinition,
|
||||
assistantDefinition,
|
||||
toolDefinition,
|
||||
commandDefinition,
|
||||
compactionDefinition,
|
||||
retryDefinition,
|
||||
turnErrorDefinition,
|
||||
turnTailDefinition,
|
||||
]
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return DEFINITIONS
|
||||
}
|
||||
|
||||
fallbackEntry(): ConversationNodeDefinition {
|
||||
return unknownFallbackDefinition
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return [chatViewDefinition]
|
||||
}
|
||||
}
|
||||
|
||||
function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
type,
|
||||
data,
|
||||
...extra,
|
||||
} as unknown as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
|
||||
const current = value.snapshot('chat') as ChatSnapshot | undefined
|
||||
if (current === undefined) throw new Error('chat view was not registered')
|
||||
return current
|
||||
}
|
||||
|
||||
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
|
||||
return value.nodes.values().find(candidate => candidate.kind === kind)
|
||||
}
|
||||
|
||||
function textMessage(id: string, text: string) {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string) {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(callId: string, text: string) {
|
||||
return {
|
||||
id: `result-${callId}`,
|
||||
role: 'user',
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('built-in conversation node Definitions', () => {
|
||||
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'streaming' },
|
||||
}),
|
||||
])
|
||||
const runningSnapshot = snapshot(value)
|
||||
const running = node(runningSnapshot, 'assistant-step')
|
||||
expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
|
||||
const order = runningSnapshot.order
|
||||
|
||||
value.append(at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-1', 'settled'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const settledSnapshot = snapshot(value)
|
||||
const settled = node(settledSnapshot, 'assistant-step')
|
||||
expect(settled?.key).toBe(running?.key)
|
||||
expect(settledSnapshot.order).toBe(order)
|
||||
expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
|
||||
|
||||
const interruptedValue = assembler([
|
||||
at(10, 'turn/start', { turn: 2 }),
|
||||
at(11, 'step/start', { turn: 2, step: 1 }),
|
||||
at(12, 'assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'partial' },
|
||||
}),
|
||||
at(13, 'step/end', { turn: 2, step: 1 }),
|
||||
])
|
||||
const interrupted = node(snapshot(interruptedValue), 'assistant-step')
|
||||
expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
|
||||
expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
|
||||
|
||||
const hiddenValue = assembler([
|
||||
at(20, 'turn/start', { turn: 3 }),
|
||||
at(21, 'step/start', { turn: 3, step: 1 }),
|
||||
at(22, 'llm/retry', {
|
||||
retryId: 'retry-hidden',
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}),
|
||||
])
|
||||
expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
|
||||
|
||||
const toolOnlyValue = assembler([
|
||||
at(30, 'turn/start', { turn: 4 }),
|
||||
at(31, 'step/start', { turn: 4, step: 1 }),
|
||||
at(32, 'assistant/chunk', {
|
||||
turn: 4,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
|
||||
}),
|
||||
at(33, 'assistant/message', {
|
||||
turn: 4,
|
||||
step: 1,
|
||||
message: {
|
||||
...assistantMessage('assistant-tool-only', ''),
|
||||
content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
const toolOnlySnapshot = snapshot(toolOnlyValue)
|
||||
expect(toolOnlySnapshot.order).toEqual([])
|
||||
expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
|
||||
expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
|
||||
kind: 'assistant',
|
||||
seq: 33,
|
||||
timing: { firstTokenTime: 1_700_000_000_032 },
|
||||
}])
|
||||
|
||||
const interruptedToolOnlyValue = assembler([
|
||||
at(35, 'turn/start', { turn: 5 }),
|
||||
at(36, 'step/start', { turn: 5, step: 1 }),
|
||||
at(37, 'assistant/chunk', {
|
||||
turn: 5,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
|
||||
}),
|
||||
at(38, 'step/end', { turn: 5, step: 1 }),
|
||||
])
|
||||
const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
|
||||
expect(interruptedToolOnly?.visibility).toBe('visible')
|
||||
expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
|
||||
|
||||
const retryTimingValue = assembler([
|
||||
at(50, 'turn/start', { turn: 6 }),
|
||||
at(51, 'step/start', { turn: 6, step: 1 }),
|
||||
at(52, 'assistant/chunk', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
|
||||
}),
|
||||
at(53, 'llm/retry', {
|
||||
retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
|
||||
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}),
|
||||
at(54, 'assistant/chunk', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
|
||||
}),
|
||||
at(55, 'assistant/message', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-retried', 'done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
|
||||
expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
|
||||
|
||||
const partialWindow = assembler([
|
||||
at(40, 'assistant/chunk', {
|
||||
turn: 5,
|
||||
step: 2,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
|
||||
}),
|
||||
at(41, 'step/end', { turn: 5, step: 2 }),
|
||||
], true)
|
||||
const recovered = node(snapshot(partialWindow), 'assistant-step')
|
||||
expect(recovered?.data).toMatchObject({
|
||||
status: 'interrupted',
|
||||
blocks: [{ kind: 'text', text: 'loaded partial' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
|
||||
])
|
||||
const runningSnapshot = snapshot(value)
|
||||
const running = node(runningSnapshot, 'tool-call')
|
||||
expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
|
||||
const order = runningSnapshot.order
|
||||
|
||||
value.append(at(4, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'done'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const settledSnapshot = snapshot(value)
|
||||
const settled = node(settledSnapshot, 'tool-call')
|
||||
expect(settled?.key).toBe(running?.key)
|
||||
expect(settledSnapshot.order).toBe(order)
|
||||
expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
|
||||
|
||||
const history = assembler([
|
||||
at(14, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
}),
|
||||
at(15, 'tool/code-dispatch', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: 'contents' }],
|
||||
}),
|
||||
at(16, 'tool/result', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
message: toolResult('history-root', 'root done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
], true)
|
||||
const before = node(snapshot(history), 'tool-call')
|
||||
expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
history.prepend([
|
||||
at(10, 'turn/start', { turn: 2 }),
|
||||
at(11, 'step/start', { turn: 2, step: 1 }),
|
||||
at(13, 'tool/call', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
callId: 'history-root',
|
||||
name: 'code',
|
||||
arguments: '{}',
|
||||
}),
|
||||
], false)
|
||||
history.flush()
|
||||
|
||||
const after = node(snapshot(history), 'tool-call')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
const firstChild = (after?.data as ToolChatData).root.subCalls[0]
|
||||
history.append(at(17, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'second-child',
|
||||
name: 'write',
|
||||
arguments: { path: 'out.txt' },
|
||||
}))
|
||||
history.flush()
|
||||
const withSecondChild = node(snapshot(history), 'tool-call')
|
||||
expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
|
||||
})
|
||||
|
||||
it('prepends an older turn without replacing already materialized nodes', () => {
|
||||
const value = assembler([
|
||||
at(20, 'turn/start', { turn: 2 }),
|
||||
at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
|
||||
at(22, 'step/start', { turn: 2, step: 1 }),
|
||||
at(23, 'assistant/message', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
message: assistantMessage('newer-assistant', 'newer answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(24, 'step/end', { turn: 2, step: 1 }),
|
||||
at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
|
||||
], true)
|
||||
const before = snapshot(value)
|
||||
const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
|
||||
const store = before.nodes
|
||||
|
||||
value.prepend([
|
||||
at(10, 'turn/start', { turn: 1 }),
|
||||
at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
|
||||
at(12, 'step/start', { turn: 1, step: 1 }),
|
||||
at(13, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('older-assistant', 'older answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(14, 'step/end', { turn: 1, step: 1 }),
|
||||
at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = snapshot(value)
|
||||
expect(after.nodes).toBe(store)
|
||||
expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
|
||||
expect(after.order).toHaveLength(before.order.length + 3)
|
||||
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('appends a later turn without replacing nodes from the completed turn', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
|
||||
at(3, 'step/start', { turn: 1, step: 1 }),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('first-assistant', 'first answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(5, 'step/end', { turn: 1, step: 1 }),
|
||||
at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
const before = snapshot(value)
|
||||
const oldOrder = before.order
|
||||
const oldNodes = oldOrder.map(key => before.nodes.get(key))
|
||||
|
||||
value.append(at(7, 'turn/start', { turn: 2 }))
|
||||
value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const after = snapshot(value)
|
||||
expect(after.nodes).toBe(before.nodes)
|
||||
expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
|
||||
expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
|
||||
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'assistant-step', 'turn-tail', 'user',
|
||||
])
|
||||
})
|
||||
|
||||
it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
|
||||
const value = assembler([
|
||||
at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
|
||||
], true)
|
||||
const before = node(snapshot(value), 'user')
|
||||
expect(before).toBeDefined()
|
||||
|
||||
value.prepend([
|
||||
at(1, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
inserted: [textMessage('steer-1', 'change direction')],
|
||||
}),
|
||||
at(2, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
removedCount: 1,
|
||||
inserted: [],
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = node(snapshot(value), 'steering')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
|
||||
expect(node(snapshot(value), 'user')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies appended producer context from durable source metadata', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', {
|
||||
...textMessage('skill-context', 'follow these instructions'),
|
||||
source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
expect(node(snapshot(value), 'context')?.data).toMatchObject({
|
||||
kind: 'context',
|
||||
provenance: { role: 'inject', label: 'demo-skill' },
|
||||
form: 'instructions',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps replacement copies out of Chat business nodes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'user/message', {
|
||||
...textMessage('replacement-user', 'model-only context'),
|
||||
source: { kind: 'plugin', plugin: 'foreign' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('replacement-assistant', 'rewritten answer'),
|
||||
}, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
|
||||
at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
|
||||
at(6, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'pruned result'),
|
||||
}, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
|
||||
])
|
||||
|
||||
const current = snapshot(value)
|
||||
expect(node(current, 'user')).toBeUndefined()
|
||||
expect(node(current, 'context')).toBeUndefined()
|
||||
expect(node(current, 'assistant-step')).toBeUndefined()
|
||||
expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
|
||||
})
|
||||
|
||||
it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
|
||||
const retry = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'llm/retry', {
|
||||
retryId: 'retry-1',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'first' },
|
||||
}),
|
||||
at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
|
||||
at(5, 'llm/retry', {
|
||||
retryId: 'retry-1',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 20,
|
||||
failure: { code: 'TRANSPORT', message: 'second' },
|
||||
}),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
|
||||
}),
|
||||
])
|
||||
const retryNode = node(snapshot(retry), 'model-retry')
|
||||
const retryData = retryNode?.data as RetryChatData
|
||||
expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
|
||||
expect(node(snapshot(retry), 'turn-error')).toBeUndefined()
|
||||
|
||||
const compactions = assembler([
|
||||
at(10, 'command/run', {
|
||||
commandId: 'command-1',
|
||||
name: 'compact',
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
at(11, 'compact/start', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
turn: null,
|
||||
}),
|
||||
at(12, 'compact/summary', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
summary: [{ type: 'text', text: 'manual summary' }],
|
||||
shadowedSeqs: [1, 2],
|
||||
shadowedTokenCount: 100,
|
||||
}),
|
||||
at(13, 'user/message', {
|
||||
...textMessage('manual-checkpoint', 'checkpoint'),
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'compact',
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
},
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
|
||||
at(14, 'compact/end', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
turn: null,
|
||||
}),
|
||||
at(15, 'command/done', {
|
||||
commandId: 'command-1',
|
||||
kind: 'success',
|
||||
sourceEventSeq: 12,
|
||||
}),
|
||||
at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }),
|
||||
at(21, 'compact/summary', {
|
||||
compactionId: 'automatic-1',
|
||||
summary: [{ type: 'text', text: 'automatic summary' }],
|
||||
shadowedSeqs: [3, 4],
|
||||
shadowedTokenCount: 200,
|
||||
}),
|
||||
at(22, 'user/message', {
|
||||
...textMessage('automatic-checkpoint', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
|
||||
}, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
|
||||
at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }),
|
||||
])
|
||||
|
||||
const manual = node(snapshot(compactions), 'manual-compaction')
|
||||
expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
|
||||
summary: 'manual summary',
|
||||
summaryEventSeq: 12,
|
||||
})
|
||||
const automatic = node(snapshot(compactions), 'compaction')
|
||||
expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
|
||||
expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fills a landed compaction marker when an older page supplies its summary', () => {
|
||||
const value = assembler([
|
||||
at(13, 'user/message', {
|
||||
...textMessage('checkpoint', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
|
||||
], true)
|
||||
const before = node(snapshot(value), 'compaction')
|
||||
expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
|
||||
|
||||
value.prepend([
|
||||
at(9, 'compact/start', { compactionId: 'compact-1', turn: null }),
|
||||
at(10, 'compact/summary', {
|
||||
compactionId: 'compact-1',
|
||||
summary: [
|
||||
{ type: 'text', text: 'older ' },
|
||||
{ type: 'image', data: 'ignored' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
shadowedSeqs: [1, 2, 3],
|
||||
shadowedTokenCount: 42,
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = node(snapshot(value), 'compaction')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect(after?.data).toMatchObject({
|
||||
summary: 'older summary',
|
||||
summaryEventSeq: 10,
|
||||
shadowedItemCount: 3,
|
||||
shadowedTokenCount: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => {
|
||||
const value = assembler([
|
||||
at(5, 'llm/retry', {
|
||||
retryId: 'retry-paged',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 20,
|
||||
failure: { code: 'TRANSPORT', message: 'second' },
|
||||
}),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
|
||||
}),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(value), 'model-retry')).toBeUndefined()
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
|
||||
value.prepend([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'llm/retry', {
|
||||
retryId: 'retry-paged',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'first' },
|
||||
}),
|
||||
at(4, 'llm/retry-started', {
|
||||
retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const retry = node(snapshot(value), 'model-retry')
|
||||
expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
|
||||
const value = assembler([
|
||||
at(12, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
|
||||
}),
|
||||
at(13, 'tool/code-dispatch', {
|
||||
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
|
||||
isError: false, content: [{ type: 'text', text: 'child result' }],
|
||||
}),
|
||||
at(14, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'root result'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(20, 'compact/summary', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
summary: [{ type: 'text', text: 'manual summary' }],
|
||||
shadowedSeqs: [1, 2],
|
||||
shadowedTokenCount: 100,
|
||||
}),
|
||||
at(21, 'user/message', {
|
||||
...textMessage('manual-checkpoint', 'checkpoint'),
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'compact',
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
},
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
|
||||
at(22, 'command/done', {
|
||||
commandId: 'command-1',
|
||||
kind: 'success',
|
||||
sourceEventSeq: 20,
|
||||
}),
|
||||
], true)
|
||||
|
||||
const tool = node(snapshot(value), 'tool-call')
|
||||
const root = (tool?.data as ToolChatData).root
|
||||
expect(root.subCalls).toHaveLength(1)
|
||||
expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
|
||||
const manual = node(snapshot(value), 'manual-compaction')
|
||||
expect((manual?.data as ManualCompactionChatData)).toMatchObject({
|
||||
command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
|
||||
compaction: { summary: 'manual summary', summaryEventSeq: 20 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -15,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -40,14 +41,15 @@ const SessionProviderStub: SessionProviderComponent = ({ children }) => children
|
||||
/** Observe the owner currency without importing the Tool details renderer. */
|
||||
function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
|
||||
return (_key, owner) => {
|
||||
owners?.push(owner as DetailsToolOwnerProps)
|
||||
owners?.push(owner as unknown as DetailsToolOwnerProps)
|
||||
return <div data-testid="tool-details-seat" />
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -69,11 +71,16 @@ describe('render branch tails', () => {
|
||||
it('StatsLine counts window nodes but drops every token group without a projection', () => {
|
||||
// Node `usage` is deliberately ignored: billing rides the durable
|
||||
// tokenUsage projection, so an absent projection leaves counts only.
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
{ kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
] as const
|
||||
const snap = {
|
||||
...snapshotBase(),
|
||||
chat: chatSnapshotFixture({ nodes }),
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
...nodes,
|
||||
],
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
@@ -146,6 +153,7 @@ describe('render branch tails', () => {
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls })
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -35,7 +35,8 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -26,7 +26,8 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -8,15 +8,22 @@
|
||||
import { Context } from 'cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
|
||||
ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition,
|
||||
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
|
||||
ConversationViewNode, ToolResultNode, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
type DeliverablesTurnData,
|
||||
} from '../src/client/turn-deliverables.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
@@ -24,101 +31,187 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user',
|
||||
seq,
|
||||
time: seq * 1000,
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
|
||||
get<Key extends keyof ConversationTurnDataMap & string>(
|
||||
key: Key,
|
||||
): Readonly<ConversationTurnDataMap[Key]> | undefined {
|
||||
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
}
|
||||
|
||||
set<Key extends keyof ConversationTurnDataMap & string>(key: Key, value: ConversationTurnDataMap[Key]): void {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => {
|
||||
const data = new TestTurnDataStore()
|
||||
if (deliverables !== undefined) data.set('deliverables', deliverables)
|
||||
return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data }
|
||||
}
|
||||
|
||||
const produced = (...values: ReadonlyArray<readonly [seq: number, path: string]>): DeliverablesTurnData => ({
|
||||
produced: values.map(([seq, path]) => ({ seq, path })),
|
||||
})
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
})
|
||||
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
|
||||
function tailOwner(
|
||||
data: DeliverablesTurnData | undefined,
|
||||
seq: number,
|
||||
openFile: (path: string) => void = () => {},
|
||||
turn = 1,
|
||||
): TurnTailOwnerProps {
|
||||
return { seq, openFile, turn: turnLocation(turn, data) }
|
||||
}
|
||||
|
||||
interface TimelineSnapshot {
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] }
|
||||
fallbackEntry(): undefined { return undefined }
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] }
|
||||
}
|
||||
|
||||
const timelineViewDefinition: ConversationViewDefinition<ConversationViewNode, TimelineSnapshot> = {
|
||||
target: 'test',
|
||||
create: () => {
|
||||
let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } }
|
||||
return {
|
||||
empty: current,
|
||||
replace: ({ timeline }) => (current = { timeline }),
|
||||
apply: ({ timeline }) => (current = { timeline }),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
view?: ConversationEventInput['view'],
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq, time: seq * 1_000, type, data,
|
||||
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
|
||||
} as ConversationEventInput['event'],
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function call(
|
||||
seq: number,
|
||||
callId: string,
|
||||
view: ToolResultNode['callView'],
|
||||
turn = 1,
|
||||
): ConversationEventInput {
|
||||
return at(
|
||||
seq,
|
||||
'tool/call',
|
||||
{ turn, step: 1, callId, name: 'fixture', arguments: '{}' },
|
||||
{ for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
|
||||
)
|
||||
}
|
||||
|
||||
function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput {
|
||||
return at(seq, 'tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { type: 'tool-result', callId },
|
||||
content: [{ type: 'tool-result', content: [], isError }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function diff(...paths: string[]): ToolResultNode['callView'] {
|
||||
return {
|
||||
card: 'diff', title: `Write ${paths[0] ?? ''}`,
|
||||
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
|
||||
locations: paths.map(path => ({ path })),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('producedForClosing derivation', () => {
|
||||
it('attributes each turn’s written files to the assistant that closes it', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing', 1),
|
||||
wrote(3, 'a', 'out/index.html'),
|
||||
// Same file touched twice in one turn is one deliverable, in first-seen order.
|
||||
wrote(4, 'b', 'out/app.css', 'out/index.html'),
|
||||
// A read is not a deliverable; a failed write has no file to open.
|
||||
{ ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } },
|
||||
{ ...wrote(6, 'd', 'out/broken.html'), isError: true },
|
||||
assistant(7, 'done', 1),
|
||||
user(8, 'again'),
|
||||
assistant(9, 'second turn', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
|
||||
// A turn that produced nothing yields the empty list, and so does an
|
||||
// anchor the window does not contain.
|
||||
expect(producedForClosing(nodes, 9)).toEqual([])
|
||||
expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([])
|
||||
expect(producedForClosing(nodes, 999)).toEqual([])
|
||||
function edit(path: string): ToolResultNode['callView'] {
|
||||
return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly<DeliverablesTurnData> | undefined {
|
||||
const snapshot = value.snapshot('test') as TimelineSnapshot
|
||||
return snapshot.timeline.turns.get(turn)?.data.get('deliverables')
|
||||
}
|
||||
|
||||
describe('produced-file Turn data', () => {
|
||||
it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => {
|
||||
const data = produced(
|
||||
[3, 'out/index.html'],
|
||||
[4, 'out/app.css'],
|
||||
[4, 'out/index.html'],
|
||||
[8, 'after.txt'],
|
||||
)
|
||||
expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(producedForClosing(undefined)).toEqual([])
|
||||
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
|
||||
})
|
||||
|
||||
it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'write', diff('out/index.html', 'out/app.css')),
|
||||
result(3, 'write'),
|
||||
call(4, 'edit', edit('notes.md')),
|
||||
result(5, 'edit'),
|
||||
call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
|
||||
result(7, 'read'),
|
||||
call(8, 'failed', diff('broken.txt')),
|
||||
result(9, 'failed', true),
|
||||
call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
|
||||
result(11, 'locationless'),
|
||||
])
|
||||
|
||||
it('counts a generic edit and never spills across the turn boundary', () => {
|
||||
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'str_replace_editor'),
|
||||
// str_replace_editor's insert mutates behind a generic card, so the
|
||||
// discriminant is the render intent, not the card shape alone.
|
||||
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
|
||||
})
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'insert a line'),
|
||||
inserted(2, 'i', 'notes.md'),
|
||||
assistant(3, 'inserted', 1),
|
||||
// Turn 2 mutates and then ends with no content text (interrupted, or its
|
||||
// last text preceded the tool): its paths must not ride into turn 3.
|
||||
user(4, 'now rewrite it'),
|
||||
wrote(5, 'w', 'leaked.txt'),
|
||||
user(6, 'and again'),
|
||||
wrote(7, 'w2', 'notes.md'),
|
||||
assistant(8, 'done', 3),
|
||||
]
|
||||
expect(producedForClosing(nodes, 3)).toEqual(['notes.md'])
|
||||
// Turn 3 lists only its own file — and the dedup set did not suppress the
|
||||
// rewrite of a path an earlier turn already touched.
|
||||
expect(producedForClosing(nodes, 8)).toEqual(['notes.md'])
|
||||
expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt')
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([
|
||||
'out/index.html', 'out/app.css', 'notes.md',
|
||||
])
|
||||
})
|
||||
|
||||
it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'go'),
|
||||
// A turnless surface node neither tracks nor resets the boundary.
|
||||
{ kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null },
|
||||
wrote(2, 'w', 'turn-one.txt'),
|
||||
// A view-less result (window truncation) and cards without locations
|
||||
// contribute nothing rather than crashing the walk.
|
||||
toolResult(3, 'plain'),
|
||||
{ ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } },
|
||||
{ ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } },
|
||||
assistant(6, 'mid narration', 1),
|
||||
// Turn number advances with no user message in the window (truncated
|
||||
// history): the accumulator must reset all the same.
|
||||
assistant(7, 'closing', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt'])
|
||||
expect(producedForClosing(nodes, 7)).toEqual([])
|
||||
it('replays a tail page once prepend supplies its missing Turn start', () => {
|
||||
const value = assembler([
|
||||
call(10, 'late', diff('history.txt')),
|
||||
result(11, 'late'),
|
||||
], true)
|
||||
expect(deliverablesOf(value)).toBeUndefined()
|
||||
|
||||
value.prepend([at(1, 'turn/start', { turn: 1 })], false)
|
||||
value.flush()
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt'])
|
||||
})
|
||||
|
||||
it('extends the same Turn data incrementally on live append', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'first', diff('first.txt')),
|
||||
result(3, 'first'),
|
||||
])
|
||||
const first = deliverablesOf(value)
|
||||
expect(producedForClosing(first)).toEqual(['first.txt'])
|
||||
|
||||
value.append(call(4, 'second', diff('second.txt')))
|
||||
value.append(result(5, 'second'))
|
||||
value.flush()
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,6 +283,7 @@ describe('plugin registration', () => {
|
||||
it('registers the tail entry and fiber disposal removes it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
// The owning view's child declaration, stood up by a bench root entry.
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
@@ -204,17 +298,17 @@ describe('plugin registration', () => {
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
const owner = {
|
||||
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
|
||||
seq: 3,
|
||||
openFile: (path: string) => { opened.push(path) },
|
||||
}
|
||||
const owner = tailOwner(
|
||||
produced([2, 'site/report.html']),
|
||||
3,
|
||||
(path) => { opened.push(path) },
|
||||
)
|
||||
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
|
||||
const mentions = service?.forClosing(owner)
|
||||
mentions?.resolve('report.html')?.open()
|
||||
expect(opened).toEqual(['site/report.html'])
|
||||
// A turn that produced nothing yields no vocabulary at all.
|
||||
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
|
||||
expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
|
||||
@@ -7,6 +7,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'surface.a': { kind: 'single'; scope: 'root' }
|
||||
'surface.b': { kind: 'single'; scope: 'root' }
|
||||
'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +29,16 @@ describe('dynamic-key escape hatch', () => {
|
||||
expect(core.spec('surface.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the parent-declared Slot inject on the runtime spec', () => {
|
||||
const core = new SlotCore()
|
||||
const inject = { token: 'shared' }
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: { 'surface.injected': { kind: 'single', scope: 'root', inject } },
|
||||
}, Comp as never)
|
||||
expect(core.spec('surface.injected')?.inject).toBe(inject)
|
||||
})
|
||||
|
||||
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.entries('surface.b')).toHaveLength(0)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
|
||||
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -19,6 +19,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'chain.frame': { kind: 'single'; scope: 'root' }
|
||||
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
|
||||
'chain.conv': { kind: 'single'; scope: 'session' }
|
||||
'chain.context': {
|
||||
kind: 'single'
|
||||
scope: 'session'
|
||||
hookContext: string
|
||||
inject: ContextInjected
|
||||
}
|
||||
'chain.tools': { kind: 'keyed'; scope: 'session' }
|
||||
'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } }
|
||||
}
|
||||
@@ -62,6 +68,23 @@ type ConvProps =
|
||||
& PropsStore<ChatHandle>
|
||||
& { send: (t: string) => void }
|
||||
|
||||
interface TurnDataMap { tail: string; files: string }
|
||||
type UseTurnData = <Key extends keyof TurnDataMap>(key: Key) => TurnDataMap[Key] | undefined
|
||||
interface ContextInjected {
|
||||
hooks: {
|
||||
turnData: SlotHookFactory<'chain.context', UseTurnData>
|
||||
}
|
||||
}
|
||||
type ContextProps = PropsRuntime<'chain.context'>
|
||||
const CONTEXT_INJECT: ContextInjected = {
|
||||
hooks: {
|
||||
turnData: (_standard, hookContext) => {
|
||||
const id: string = hookContext
|
||||
return key => id === '' ? undefined : ({ tail: 'tail', files: 'files' })[key]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Component fixtures (never rendered; the register call sites are the test).
|
||||
declare function Frame(props: FrameProps): ReactNode
|
||||
declare function Conv(props: ConvProps): ReactNode
|
||||
@@ -72,6 +95,8 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c
|
||||
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
|
||||
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
|
||||
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
|
||||
declare function ContextOwner(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.context'>): ReactNode
|
||||
declare function ContextReader(props: ContextProps): ReactNode
|
||||
declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode
|
||||
declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode
|
||||
declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode
|
||||
@@ -144,6 +169,24 @@ describe('terminal-design type chain', () => {
|
||||
chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null })
|
||||
chainSlots.renderSlot('chain.conv', {})
|
||||
|
||||
// A parent registration declares the Slot inject once; every child
|
||||
// entry receives the same custom Hook, bound to official standard props
|
||||
// and each render occurrence's opaque context.
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
children: {
|
||||
'chain.context': { kind: 'single', scope: 'session', inject: CONTEXT_INJECT },
|
||||
},
|
||||
}, ContextOwner)
|
||||
core.register({ name: 'chain.context' }, ContextReader)
|
||||
const contextProps: ContextProps = null as never
|
||||
const tail: string | undefined = contextProps.useTurnData('tail')
|
||||
const contextSlots: PropsRenderSlots<'chain.context'> = null as never
|
||||
contextSlots.renderSlot('chain.context', {}, {
|
||||
hookContext: 'turn:1',
|
||||
})
|
||||
void tail
|
||||
|
||||
// ── negatives ──────────────────────────────────────────────────
|
||||
// children spec must match the SlotMap entry.
|
||||
core.register({
|
||||
@@ -151,6 +194,11 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error chain.conv is session-scoped in SlotMap
|
||||
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
|
||||
}, (() => null) as SlotComponent<never>)
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
// @ts-expect-error chain.context requires its Slot-level inject declaration
|
||||
children: { 'chain.context': { kind: 'single', scope: 'session' } },
|
||||
}, ContextOwner)
|
||||
|
||||
// renderSlot key set ⊄ children declaration.
|
||||
// @ts-expect-error component renderSlot keys exceed the declaration
|
||||
@@ -219,6 +267,16 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error key not in this render share
|
||||
fp.renderSlot('chain.tools', {})
|
||||
|
||||
// Contextual hooks preserve both the business key and value type.
|
||||
// @ts-expect-error unknown Turn-data key
|
||||
contextProps.useTurnData('other')
|
||||
// @ts-expect-error a contextual slot requires its occurrence context
|
||||
contextSlots.renderSlot('chain.context', {})
|
||||
// @ts-expect-error hookContext is the slot-declared string
|
||||
const _wrongContextFactory: SlotHookFactory<'chain.context', UseTurnData> =
|
||||
(_standard, _hookContext: number) => () => undefined
|
||||
void _wrongContextFactory
|
||||
|
||||
// baked actions strip the draft parameter.
|
||||
acts.setDraft('x')
|
||||
// @ts-expect-error wrong payload type
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
|
||||
import { toolChatSnapshot } from './tool-details-render.tsx'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
@@ -74,7 +75,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
snapshot: { nodes },
|
||||
snapshot: { nodes, chat: toolChatSnapshot(nodes) },
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
|
||||
ToolCallBlock, ToolResultNode, WorkspaceListState,
|
||||
@@ -21,6 +23,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
|
||||
import { toolChatSnapshot } from './tool-details-render.tsx'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
@@ -75,7 +78,8 @@ function snapshotWith(
|
||||
const nestedNodes = nodes.map(node => ({ ...node, subCalls }))
|
||||
const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls }))
|
||||
return {
|
||||
sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
|
||||
sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls),
|
||||
nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
|
||||
runningCalls: nestedRunningCalls,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
@@ -97,6 +101,8 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
|
||||
import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx'
|
||||
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
|
||||
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -342,8 +342,11 @@ describe('DetailsPanel diff Output section', () => {
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
const nodes = over.nodes ?? []
|
||||
const runningCalls = over.runningCalls ?? []
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
|
||||
import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx'
|
||||
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
|
||||
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -288,8 +288,11 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
const nodes = over.nodes ?? []
|
||||
const runningCalls = over.runningCalls ?? []
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
|
||||
import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx'
|
||||
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
|
||||
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
|
||||
|
||||
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
|
||||
type SearchRowProps = Parameters<typeof SearchRow>[0]
|
||||
@@ -404,8 +404,11 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
const nodes = over.nodes ?? []
|
||||
const runningCalls = over.runningCalls ?? []
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
|
||||
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
|
||||
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
@@ -479,8 +479,11 @@ describe('DetailsPanel Output section', () => {
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
const nodes = over.nodes ?? []
|
||||
const runningCalls = over.runningCalls ?? []
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -29,12 +29,21 @@ function props(
|
||||
return {
|
||||
useSession,
|
||||
renderSlot,
|
||||
callId: block.callId,
|
||||
toolName: block.call?.name ?? '',
|
||||
block,
|
||||
node: {
|
||||
key: `tool:${block.callId}`,
|
||||
kind: 'tool-call',
|
||||
id: block.callId,
|
||||
target: 'chat',
|
||||
anchorSeq: block.seq,
|
||||
location: { kind: 'session' },
|
||||
visibility: 'visible',
|
||||
data: { root: block },
|
||||
},
|
||||
selectedCallId,
|
||||
openFile: vi.fn(),
|
||||
inspectCall: vi.fn(),
|
||||
forkAt: vi.fn(),
|
||||
fileMentions: vi.fn(),
|
||||
t,
|
||||
} as unknown as ToolTreeProps
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Test adapter for the production conversation.details.tool registration. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts'
|
||||
import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
|
||||
@@ -7,6 +9,45 @@ import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
|
||||
/** Framework session-area seat used by direct DetailsPanel tests. */
|
||||
export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId)
|
||||
|
||||
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
|
||||
export function toolChatSnapshot(
|
||||
settled: readonly ConversationNode[] = [],
|
||||
running: readonly RunningToolCall[] = [],
|
||||
): ChatSnapshot {
|
||||
const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running]
|
||||
const nodes: ChatConversationViewNode[] = roots.map(root => ({
|
||||
key: `tool:${root.callId}`,
|
||||
kind: 'tool-call',
|
||||
id: root.callId,
|
||||
target: 'chat',
|
||||
anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER,
|
||||
location: { kind: 'session' },
|
||||
visibility: 'visible',
|
||||
data: { root },
|
||||
}))
|
||||
const byKey = new Map(nodes.map(node => [node.key, node]))
|
||||
const empty: readonly string[] = []
|
||||
return {
|
||||
order: nodes.map(node => node.key),
|
||||
nodes: {
|
||||
get: key => byKey.get(key),
|
||||
values: () => nodes,
|
||||
},
|
||||
locations: {
|
||||
getTurn: () => empty,
|
||||
getStep: () => empty,
|
||||
},
|
||||
timeline: { turnOrder: [], turns: new Map() },
|
||||
legacy: {
|
||||
nodes: settled,
|
||||
runningCalls: running,
|
||||
partial: null,
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind ui-tool's details renderer to the conversation slot callback shape.
|
||||
* @param t - conversation locale seat used by Tool cards.
|
||||
@@ -16,7 +57,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr
|
||||
return (_key, owner) => {
|
||||
// PropsRenderSlots keeps its key generic even for this one-key share;
|
||||
// recover the concrete owner selected by the adapter's fixed slot.
|
||||
const details = owner as DetailsToolOwnerProps
|
||||
const details = owner as unknown as DetailsToolOwnerProps
|
||||
return <ToolDetails block={details.block} cwd={details.cwd} t={t} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { toolChatSnapshot } from './tool-details-render.tsx'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
@@ -71,7 +72,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
snapshot: { nodes },
|
||||
snapshot: { nodes, chat: toolChatSnapshot(nodes) },
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
|
||||
import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx'
|
||||
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
|
||||
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
@@ -234,8 +234,11 @@ describe('DetailsPanel web Output section', () => {
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
const nodes = over.nodes ?? []
|
||||
const runningCalls = over.runningCalls ?? []
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -90,6 +90,7 @@ function makeHost() {
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
let currentId: string | undefined
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
const sessionSources = new Map<string, ReturnType<typeof observable<unknown>>>()
|
||||
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
@@ -160,17 +161,24 @@ function makeHost() {
|
||||
bump(key)
|
||||
}
|
||||
},
|
||||
addSession: (id: string): SessionProvideInfo => {
|
||||
addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => {
|
||||
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
|
||||
const session = observable<unknown>(initial)
|
||||
const info: SessionProvideInfo = {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
|
||||
hooks: { session },
|
||||
props: {},
|
||||
}
|
||||
sessionSources.set(id, session)
|
||||
infos.set(id, info)
|
||||
if (currentId === id) provide.set(info)
|
||||
return info
|
||||
},
|
||||
setSession: (id: string, snapshot: unknown) => {
|
||||
const source = sessionSources.get(id)
|
||||
if (source === undefined) throw new Error(`unknown test session: ${id}`)
|
||||
source.set(snapshot)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,6 +643,71 @@ describe('standard-kit synthesis', () => {
|
||||
expect(props['sessionId']).toBe('s1')
|
||||
})
|
||||
|
||||
it('binds only function-valued inject hooks to the standard kit and render occurrence context', () => {
|
||||
const h = makeHost()
|
||||
const turnDataFactory = vi.fn((standard: AnyProps, turn: unknown) => (key: string) => {
|
||||
const useSession = standard['useSession'] as (selector: (snapshot: unknown) => unknown) => unknown
|
||||
return useSession((snapshot) => {
|
||||
const value = snapshot as { turns: Record<number, Record<string, unknown>> }
|
||||
return value.turns[turn as number]?.[key]
|
||||
})
|
||||
})
|
||||
const sessionSpec: DeclaredSpec = {
|
||||
kind: 'single', scope: 'session', inject: { hooks: { turnData: turnDataFactory } },
|
||||
}
|
||||
h.declare('k.session', sessionSpec)
|
||||
h.addSession('s1', {
|
||||
turns: { 1: { tail: 'one' }, 2: { tail: 'two' } },
|
||||
unrelated: 0,
|
||||
})
|
||||
const hooks = new Map<string, Array<(key: string) => unknown>>()
|
||||
let renders = 0
|
||||
h.add('k.session', {
|
||||
component: ({ label, useTurnData }: { label: string; useTurnData: (key: string) => unknown }) => {
|
||||
renders += 1
|
||||
const seen = hooks.get(label) ?? []
|
||||
seen.push(useTurnData)
|
||||
hooks.set(label, seen)
|
||||
return <b data-turn={label}>{String(useTurnData('tail'))}</b>
|
||||
},
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => (
|
||||
<SessionProvider>{() => <>
|
||||
{renderSlot('k.session', { label: 'one' }, { hookContext: 1 })}
|
||||
{renderSlot('k.session', { label: 'two' }, { hookContext: 2 })}
|
||||
</>}
|
||||
</SessionProvider>
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(view.container.textContent).toBe('onetwo')
|
||||
expect(renders).toBe(2)
|
||||
|
||||
// A session publication whose selected contextual value is identical is
|
||||
// filtered by the framework-bound selector.
|
||||
act(() => {
|
||||
h.setSession('s1', {
|
||||
turns: { 1: { tail: 'one' }, 2: { tail: 'two' } },
|
||||
unrelated: 1,
|
||||
})
|
||||
})
|
||||
expect(renders).toBe(2)
|
||||
|
||||
// Updating the selected contextual value re-renders through the returned
|
||||
// custom Hook; the factory itself and its Hook identity stay stable.
|
||||
act(() => {
|
||||
h.setSession('s1', {
|
||||
turns: { 1: { tail: 'updated' }, 2: { tail: 'two' } },
|
||||
unrelated: 1,
|
||||
})
|
||||
})
|
||||
expect(view.container.textContent).toBe('updatedtwo')
|
||||
expect(renders).toBe(3)
|
||||
expect(hooks.get('one')![1]).toBe(hooks.get('one')![0])
|
||||
expect(hooks.get('two')).toHaveLength(1)
|
||||
expect(hooks.get('one')![0]).not.toBe(hooks.get('two')![0])
|
||||
expect(turnDataFactory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
|
||||
@@ -4,6 +4,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import {
|
||||
CompactionId,
|
||||
CompactService,
|
||||
ManualCompactionError,
|
||||
type CompactAgentContext,
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
const COMPACTION_ID = CompactionId('command-compact-test')
|
||||
|
||||
const RESULT: CompactionResult = {
|
||||
compactionId: COMPACTION_ID,
|
||||
startSeq: 1,
|
||||
summarySeq: 2,
|
||||
endSeq: 3,
|
||||
@@ -45,18 +49,28 @@ class StubCompactService extends CompactService {
|
||||
override compactNow(
|
||||
agent: ManualCompactAgentContext,
|
||||
signal: AbortSignal,
|
||||
sourceCommandId?: Parameters<CompactService['compactNow']>[2],
|
||||
): Promise<CompactionResult | null> {
|
||||
this.calls.push({ agent, signal })
|
||||
if (this.operation !== undefined) return this.operation()
|
||||
return this.failure === undefined
|
||||
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result))
|
||||
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId))
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
|
||||
: Promise.reject(this.failure)
|
||||
}
|
||||
|
||||
private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult {
|
||||
agent.session.append('compact/start', { turn: null })
|
||||
private appendResult(
|
||||
agent: ManualCompactAgentContext,
|
||||
result: CompactionResult,
|
||||
sourceCommandId: Parameters<CompactService['compactNow']>[2],
|
||||
): CompactionResult {
|
||||
const provenance = {
|
||||
compactionId: result.compactionId,
|
||||
...sourceCommandId === undefined ? {} : { sourceCommandId },
|
||||
}
|
||||
agent.session.append('compact/start', { ...provenance, turn: null })
|
||||
agent.session.append('compact/summary', {
|
||||
...provenance,
|
||||
summary: result.summary,
|
||||
shadowedRange: result.shadowedRange,
|
||||
shadowedSeqs: result.shadowedSeqs,
|
||||
@@ -64,8 +78,8 @@ class StubCompactService extends CompactService {
|
||||
provider: 'command-test',
|
||||
model: 'command-test',
|
||||
})
|
||||
agent.session.append('compact/end', { turn: null })
|
||||
return result
|
||||
agent.session.append('compact/end', { ...provenance, turn: null })
|
||||
return { ...result, ...provenance }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import {
|
||||
CompactionId,
|
||||
CompactService,
|
||||
type CompactAgentContext,
|
||||
type CompactionResult,
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const COMPACTION_ID = CompactionId('loader-command-compact-test')
|
||||
|
||||
const RESULT: CompactionResult = {
|
||||
compactionId: COMPACTION_ID,
|
||||
startSeq: 1,
|
||||
summarySeq: 2,
|
||||
endSeq: 3,
|
||||
@@ -44,9 +48,15 @@ class LoaderCompactService extends CompactService {
|
||||
override compactNow(
|
||||
agent: ManualCompactAgentContext,
|
||||
_signal: AbortSignal,
|
||||
sourceCommandId?: Parameters<CompactService['compactNow']>[2],
|
||||
): Promise<CompactionResult | null> {
|
||||
agent.session.append('compact/start', { turn: null })
|
||||
const provenance = {
|
||||
compactionId: RESULT.compactionId,
|
||||
...sourceCommandId === undefined ? {} : { sourceCommandId },
|
||||
}
|
||||
agent.session.append('compact/start', { ...provenance, turn: null })
|
||||
agent.session.append('compact/summary', {
|
||||
...provenance,
|
||||
summary: RESULT.summary,
|
||||
shadowedRange: RESULT.shadowedRange,
|
||||
shadowedSeqs: RESULT.shadowedSeqs,
|
||||
@@ -54,8 +64,8 @@ class LoaderCompactService extends CompactService {
|
||||
provider: 'loader-test',
|
||||
model: 'loader-test',
|
||||
})
|
||||
agent.session.append('compact/end', { turn: null })
|
||||
return Promise.resolve(RESULT)
|
||||
agent.session.append('compact/end', { ...provenance, turn: null })
|
||||
return Promise.resolve({ ...RESULT, ...provenance })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +142,17 @@ describe('command-compact real Loader composition', () => {
|
||||
},
|
||||
{
|
||||
type: 'compact/start',
|
||||
data: { turn: null },
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
sourceCommandId: execution.commandId,
|
||||
turn: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
sourceCommandId: execution.commandId,
|
||||
summary: RESULT.summary,
|
||||
shadowedRange: RESULT.shadowedRange,
|
||||
shadowedSeqs: RESULT.shadowedSeqs,
|
||||
@@ -147,7 +163,11 @@ describe('command-compact real Loader composition', () => {
|
||||
},
|
||||
{
|
||||
type: 'compact/end',
|
||||
data: { turn: null },
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
sourceCommandId: execution.commandId,
|
||||
turn: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'command/done',
|
||||
|
||||
@@ -4,7 +4,7 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||
import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import {
|
||||
resolveCompactSpec,
|
||||
resolveConfig,
|
||||
@@ -945,7 +945,10 @@ describe('compaction region transaction', () => {
|
||||
)).rejects.toThrow(/no open turn/)
|
||||
|
||||
const locked = conversation(1)
|
||||
locked.append('compact/start', { turn: 2 })
|
||||
locked.append('compact/start', {
|
||||
compactionId: CompactionId('locked-compaction'),
|
||||
turn: 2,
|
||||
})
|
||||
const lockedNodes = locked.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
lockedNodes[0]!,
|
||||
@@ -1623,6 +1626,7 @@ describe('automatic listener and loader composition', () => {
|
||||
const compact = new TestCompactService(ctx)
|
||||
const session = conversation(2)
|
||||
const fakeResult: CompactionResult = {
|
||||
compactionId: CompactionId('fake-compaction'),
|
||||
startSeq: 1,
|
||||
summarySeq: 2,
|
||||
endSeq: 3,
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Context } from 'cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
|
||||
import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import {
|
||||
createAssistantMessage,
|
||||
@@ -23,7 +24,7 @@ import type {
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -394,22 +395,35 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2, 7)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const commandId = CommandId('manual-compact-command')
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL)
|
||||
const result = await compact.compactNow(agent, SIGNAL, commandId)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.sourceCommandId).toBe(commandId)
|
||||
expect(flushes()).toBe(1)
|
||||
expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
|
||||
expect(session.events.findLast(event => event.type === 'compact/start')?.data)
|
||||
.toEqual({ turn: null })
|
||||
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
|
||||
.toEqual({ turn: null })
|
||||
const start = session.events.findLast(event => event.type === 'compact/start')
|
||||
const summaryEvent = session.events.findLast(event => event.type === 'compact/summary')
|
||||
const checkpoint = session.events.findLast(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source),
|
||||
)
|
||||
const end = session.events.findLast(event => event.type === 'compact/end')
|
||||
const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId }
|
||||
expect(start?.data).toEqual({ ...correlated, turn: null })
|
||||
expect(summaryEvent?.data.sourceCommandId).toBe(commandId)
|
||||
expect(checkpoint?.data.source).toMatchObject(correlated)
|
||||
expect(end?.data).toEqual({ ...correlated, turn: null })
|
||||
})
|
||||
|
||||
it('reports a live unmatched bracket as busy without summarizing', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
session.append('compact/start', { turn: null })
|
||||
session.append('compact/start', {
|
||||
compactionId: CompactionId('live-manual-compaction'),
|
||||
turn: null,
|
||||
})
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const error = await rejection(() => compact.compactNow(agent, SIGNAL))
|
||||
@@ -421,7 +435,10 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('ignores an unmatched bracket inherited before a later end-seed marker', async () => {
|
||||
const { compact } = detachedService()
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
original.append('compact/start', {
|
||||
compactionId: CompactionId('stale-manual-compaction'),
|
||||
turn: null,
|
||||
})
|
||||
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
|
||||
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
|
||||
const orphan = reloaded.events.find(event => event.type === 'compact/start')
|
||||
@@ -435,7 +452,10 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('scans a stale orphan independently of later repaired turn state', async () => {
|
||||
const { compact } = detachedService()
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
original.append('compact/start', {
|
||||
compactionId: CompactionId('reloaded-manual-compaction'),
|
||||
turn: null,
|
||||
})
|
||||
original.append('turn/start', { turn: 3 })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
|
||||
@@ -664,7 +684,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
expect(result).not.toBeNull()
|
||||
expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(session.events.find(event => event.type === 'compact/start')?.data)
|
||||
.toEqual({ turn: null })
|
||||
.toEqual({ compactionId: result?.compactionId, turn: null })
|
||||
})
|
||||
|
||||
it('classifies a durability failure after the standalone bracket committed', async () => {
|
||||
@@ -676,8 +696,9 @@ describe('compactNow transaction and failure classification', () => {
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
|
||||
vi.restoreAllMocks()
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
|
||||
.toEqual({ turn: null })
|
||||
const start = session.events.findLast(event => event.type === 'compact/start')
|
||||
const end = session.events.findLast(event => event.type === 'compact/end')
|
||||
expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null })
|
||||
})
|
||||
|
||||
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
COMPACT_CHECKPOINT_SOURCE,
|
||||
CompactionId,
|
||||
CompactService,
|
||||
compactCheckpointSource,
|
||||
isCompactCheckpointSource,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
@@ -52,9 +53,11 @@ class StubCompactService extends CompactService {
|
||||
const endIndex = surface.indexOf(end)
|
||||
if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid')
|
||||
const shadowedSeqs = surface.slice(startIndex, endIndex + 1)
|
||||
const compactionId = CompactionId('stub-compaction')
|
||||
// Minimal stub honoring the lock + log-only event contract.
|
||||
const startEvent = session.append('compact/start', { turn: 0 })
|
||||
const startEvent = session.append('compact/start', { compactionId, turn: 0 })
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
compactionId,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
@@ -64,13 +67,14 @@ class StubCompactService extends CompactService {
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: summary,
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
source: compactCheckpointSource(compactionId),
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: 0 })
|
||||
const endEvent = session.append('compact/end', { compactionId, turn: 0 })
|
||||
return {
|
||||
compactionId,
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
@@ -139,7 +143,8 @@ describe('CompactService seam', () => {
|
||||
expect(result.shadowedSeqs).toEqual([original.seq])
|
||||
const checkpoint = session.events.find(event => event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source))
|
||||
expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE)
|
||||
expect(checkpoint?.type === 'user/message' && checkpoint.data.source)
|
||||
.toEqual(compactCheckpointSource(result.compactionId))
|
||||
expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false)
|
||||
expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false)
|
||||
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact'
|
||||
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
@@ -12,7 +15,13 @@ async function setup(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
const TEST_COMPACTION_ID = CompactionId('test-compaction')
|
||||
const NEXT_COMPACTION_ID = CompactionId('next-test-compaction')
|
||||
const TEST_COMMAND_ID = CommandId('test-command')
|
||||
const NEXT_COMMAND_ID = CommandId('next-test-command')
|
||||
|
||||
const summary = (overrides: Record<string, unknown> = {}) => ({
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
summary: [{ type: 'text' as const, text: 'short' }],
|
||||
shadowedRange: { start: 2, end: 4 },
|
||||
shadowedSeqs: [2, 3, 4],
|
||||
@@ -31,33 +40,33 @@ describe('compaction invariants', () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
startTurn(success)
|
||||
success.append('compact/start', { turn: 1 })
|
||||
success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: 1 })
|
||||
success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
startTurn(failed, 2)
|
||||
failed.append('compact/start', { turn: 2 })
|
||||
failed.append('compact/end', { turn: 2, error: 'provider failed' })
|
||||
failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })
|
||||
failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
success.append('compact/start', { turn: null })
|
||||
success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: null })
|
||||
success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
failed.append('compact/start', { turn: null })
|
||||
failed.append('compact/end', { turn: null, error: 'provider failed' })
|
||||
failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('clears an inherited open compaction trace at end-seed during replay', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = Session.create(SessionId('stale-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
@@ -68,8 +77,8 @@ describe('compaction invariants', () => {
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => {
|
||||
replayed.append('compact/start', { turn: null })
|
||||
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
|
||||
replayed.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null })
|
||||
replayed.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -78,7 +87,7 @@ describe('compaction invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = Session.create(SessionId('stale-numbered-compaction-source'))
|
||||
startTurn(source)
|
||||
source.append('compact/start', { turn: 1 })
|
||||
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
@@ -98,7 +107,7 @@ describe('compaction invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = Session.create(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
|
||||
@@ -124,10 +133,10 @@ describe('compaction invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = Session.create(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
|
||||
source.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' })
|
||||
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
@@ -143,10 +152,14 @@ describe('compaction invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow()
|
||||
expect(() => session.append('compact/end', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
turn: 1,
|
||||
error: 'resume failed',
|
||||
})).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
@@ -162,7 +175,8 @@ describe('compaction invariants', () => {
|
||||
type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 },
|
||||
})
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'compact/start', seq: 2, time: 2, data: { turn: 1 },
|
||||
type: 'compact/start', seq: 2, time: 2,
|
||||
data: { compactionId: TEST_COMPACTION_ID, turn: 1 },
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
@@ -170,28 +184,30 @@ describe('compaction invariants', () => {
|
||||
it('rejects compaction outside or for a different open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/)
|
||||
expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }))
|
||||
.toThrow(/outside any open turn/)
|
||||
startTurn(session)
|
||||
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
|
||||
expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 }))
|
||||
.toThrow(/but open turn is 1/)
|
||||
})
|
||||
|
||||
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const open = ctx.sessions.create()
|
||||
startTurn(open)
|
||||
expect(() => open.append('compact/start', { turn: null }))
|
||||
expect(() => open.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }))
|
||||
.toThrow(/standalone but turn 1 is open/)
|
||||
|
||||
const idle = ctx.sessions.create()
|
||||
expect(() => idle.append('compact/start', { turn: 1 }))
|
||||
expect(() => idle.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('attributes a nested standalone start to the standalone owner', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('compact/start', { turn: null })
|
||||
expect(() => session.append('compact/start', { turn: null }))
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
expect(() => session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null }))
|
||||
.toThrow(/standalone compaction is still compacting/)
|
||||
})
|
||||
|
||||
@@ -201,7 +217,7 @@ describe('compaction invariants', () => {
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
@@ -209,10 +225,10 @@ describe('compaction invariants', () => {
|
||||
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
|
||||
const ctx = await setup()
|
||||
const standalone = ctx.sessions.create()
|
||||
standalone.append('compact/start', { turn: null })
|
||||
standalone.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
|
||||
expect(() => { startTurn(standalone) })
|
||||
.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
standalone.append('compact/end', { turn: null, error: 'cancelled' })
|
||||
standalone.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' })
|
||||
expect(() => {
|
||||
startTurn(standalone)
|
||||
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -220,53 +236,151 @@ describe('compaction invariants', () => {
|
||||
|
||||
const numbered = ctx.sessions.create()
|
||||
startTurn(numbered)
|
||||
numbered.append('compact/start', { turn: 1 })
|
||||
numbered.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
|
||||
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
|
||||
numbered.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'cancelled' })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a replacement checkpoint for another compaction transaction', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
const original = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
startTurn(session)
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary())
|
||||
|
||||
expect(() => session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: compactCheckpointSource(NEXT_COMPACTION_ID),
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/compaction checkpoint id .* does not match compact\/start id/)
|
||||
})
|
||||
|
||||
it('requires checkpoint provenance to name an open transaction', async () => {
|
||||
const ctx = await setup()
|
||||
const withoutStart = ctx.sessions.create()
|
||||
const original = withoutStart.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(() => withoutStart.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: compactCheckpointSource(TEST_COMPACTION_ID),
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/no matching compact\/start/)
|
||||
|
||||
const emptyCommand = ctx.sessions.create()
|
||||
const replaced = emptyCommand.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
startTurn(emptyCommand)
|
||||
emptyCommand.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
expect(() => emptyCommand.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: compactCheckpointSource(TEST_COMPACTION_ID, CommandId('')),
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: replaced.seq, end: replaced.seq },
|
||||
sourceEventSeqs: [replaced.seq],
|
||||
})).toThrow(/checkpoint sourceCommandId must be a non-empty string/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty start id', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { compactionId: CompactionId(''), turn: 1 })
|
||||
}, /compact\/start compactionId must be a non-empty string/],
|
||||
['empty start source command id', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
sourceCommandId: CommandId(''),
|
||||
turn: 1,
|
||||
})
|
||||
}, /compact\/start sourceCommandId must be a non-empty string/],
|
||||
['summary without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/summary', summary())
|
||||
}, /no matching compact\/start/],
|
||||
['nested start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { turn: 2 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 })
|
||||
}, /still compacting/],
|
||||
['repeated summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary())
|
||||
session.append('compact/summary', summary())
|
||||
}, /repeated within one compaction/],
|
||||
['summary for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary({ compactionId: NEXT_COMPACTION_ID }))
|
||||
}, /compact\/summary id .* does not match compact\/start id/],
|
||||
['summary for another source command', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
sourceCommandId: TEST_COMMAND_ID,
|
||||
turn: 1,
|
||||
})
|
||||
session.append('compact/summary', summary({ sourceCommandId: NEXT_COMMAND_ID }))
|
||||
}, /compact\/summary sourceCommandId .* does not match compact\/start sourceCommandId/],
|
||||
['empty shadow set', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedSeqs: [] }))
|
||||
}, /shadowedSeqs must be non-empty/],
|
||||
['wrong endpoints', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } }))
|
||||
}, /shadowedRange must match/],
|
||||
['invalid token count', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedTokenCount: -1 }))
|
||||
}, /non-negative safe integer/],
|
||||
['end without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/end', { turn: 1, error: 'failed' })
|
||||
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
|
||||
}, /no matching compact\/start/],
|
||||
['wrong end turn', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 2, error: 'failed' })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' })
|
||||
}, /does not match/],
|
||||
['end for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' })
|
||||
}, /compact\/end id .* does not match compact\/start id/],
|
||||
['end missing the source command', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
sourceCommandId: TEST_COMMAND_ID,
|
||||
turn: 1,
|
||||
})
|
||||
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
|
||||
}, /compact\/end sourceCommandId .* does not match compact\/start sourceCommandId/],
|
||||
['empty end source command id', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
sourceCommandId: TEST_COMMAND_ID,
|
||||
turn: 1,
|
||||
})
|
||||
session.append('compact/end', {
|
||||
compactionId: TEST_COMPACTION_ID,
|
||||
sourceCommandId: CommandId(''),
|
||||
turn: 1,
|
||||
error: 'failed',
|
||||
})
|
||||
}, /compact\/end sourceCommandId must be a non-empty string/],
|
||||
['success without summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 1 })
|
||||
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
|
||||
}, /requires one compact\/summary/],
|
||||
])('rejects %s', async (_name, action, message) => {
|
||||
const ctx = await setup()
|
||||
|
||||
@@ -196,11 +196,15 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
}
|
||||
|
||||
function stubToolExecution(
|
||||
input: Omit<ToolExecution, 'token'> & { token?: ToolExecutionToken },
|
||||
input: Omit<ToolExecution, 'token' | 'rootCallId'> & {
|
||||
token?: ToolExecutionToken
|
||||
rootCallId?: ToolExecution['rootCallId']
|
||||
},
|
||||
): ToolExecution {
|
||||
return {
|
||||
token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||||
...input,
|
||||
rootCallId: input.rootCallId ?? input.callId,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -785,11 +785,11 @@ describe('the run_code dispatch bridge', () => {
|
||||
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
|
||||
expect(dispatches.map(event => event.data)).toEqual([
|
||||
{
|
||||
parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo',
|
||||
rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo',
|
||||
arguments: { value: 'one' }, isError: false, content: [{ type: 'text', text: 'echo:one' }],
|
||||
},
|
||||
{
|
||||
parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo',
|
||||
rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo',
|
||||
arguments: { value: 'two' }, isError: false, content: [{ type: 'text', text: 'echo:two' }],
|
||||
},
|
||||
])
|
||||
@@ -1526,6 +1526,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('tool/code-dispatch', {
|
||||
rootCallId: CallId('p1'),
|
||||
parentCallId: CallId('p1'),
|
||||
subCallId: CallId('p1:code:1'),
|
||||
name: 'echo',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -24,6 +24,7 @@ const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
|
||||
arguments: Object.freeze({ text: 'hi' }),
|
||||
...overrides,
|
||||
signal: overrides.signal ?? testToolSignal,
|
||||
rootCallId: overrides.rootCallId ?? overrides.callId ?? CallId('call-1'),
|
||||
})
|
||||
|
||||
const outcome = (): ToolExecutionResult => Object.freeze({
|
||||
@@ -92,6 +93,7 @@ describe('tool-pipeline invariants', () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
const data = {
|
||||
rootCallId: CallId('parent'),
|
||||
parentCallId: CallId('parent'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
@@ -103,12 +105,112 @@ describe('tool-pipeline invariants', () => {
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('does not commit a rejected dispatch edge into the root index', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('rejected-root'),
|
||||
parentCallId: CallId('rejected-root'),
|
||||
subCallId: CallId('reused-child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})).toThrow(/outside any open turn/)
|
||||
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('accepted-root'),
|
||||
parentCallId: CallId('accepted-root'),
|
||||
subCallId: CallId('reused-child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a nested code dispatch that changes its parent chain root before append', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('root'),
|
||||
parentCallId: CallId('root'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'run_code',
|
||||
arguments: {},
|
||||
})
|
||||
session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('root'),
|
||||
parentCallId: CallId('child'),
|
||||
subCallId: CallId('grandchild'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})
|
||||
|
||||
expect(() => session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('another-root'),
|
||||
parentCallId: CallId('child'),
|
||||
subCallId: CallId('invalid-grandchild'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})).toThrow(/parentCallId child does not belong to rootCallId another-root/)
|
||||
expect(session.events.some(event => event.type === 'tool/code-dispatch-start'
|
||||
&& String(event.data.subCallId) === 'invalid-grandchild')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires non-empty dispatch identities and keeps one subcall on one root', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId(''),
|
||||
parentCallId: CallId('root'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})).toThrow(/must carry non-empty rootCallId/)
|
||||
|
||||
session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('root'),
|
||||
parentCallId: CallId('root'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})
|
||||
expect(() => session.append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('other-root'),
|
||||
parentCallId: CallId('other-root'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
})).toThrow(/changed rootCallId for subCallId child/)
|
||||
})
|
||||
|
||||
it('indexes dispatch records emitted for a bare session', async () => {
|
||||
const ctx = await setup()
|
||||
const session = Session.create(SessionId('bare-dispatch-session'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session as never, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
seq: 1,
|
||||
time: 1,
|
||||
data: {
|
||||
rootCallId: CallId('root'),
|
||||
parentCallId: CallId('root'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
arguments: {},
|
||||
},
|
||||
} as never)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('replays enclosed code-dispatch records on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/code-dispatch', {
|
||||
rootCallId: CallId('parent'),
|
||||
parentCallId: CallId('parent'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
@@ -125,6 +227,7 @@ describe('tool-pipeline invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('tool/code-dispatch-start', {
|
||||
rootCallId: CallId('parent'),
|
||||
parentCallId: CallId('parent'),
|
||||
subCallId: CallId('child'),
|
||||
name: 'echo',
|
||||
|
||||
@@ -574,6 +574,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'],
|
||||
callId: CallId('agent-core-dsh-home'),
|
||||
rootCallId: CallId('agent-core-dsh-home'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
|
||||
import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand'
|
||||
import { providerForOpenStep } from '../src/history.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
@@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) {
|
||||
|
||||
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
|
||||
const normal = {
|
||||
retryId: RetryId('normal-retry-chain'),
|
||||
provider: 'mock',
|
||||
mode: 'normal' as const,
|
||||
policyKey: 'normal-policy',
|
||||
@@ -47,6 +49,7 @@ const normal = {
|
||||
failure,
|
||||
}
|
||||
const always = {
|
||||
retryId: RetryId('always-retry-chain'),
|
||||
provider: 'mock',
|
||||
mode: 'always' as const,
|
||||
policyKey: 'always-policy',
|
||||
@@ -130,6 +133,7 @@ describe('llm-retry invariants', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/],
|
||||
['retry-zero', { ...normal, retry: 0 }, /positive safe integer/],
|
||||
['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/],
|
||||
['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/],
|
||||
@@ -212,10 +216,65 @@ describe('llm-retry invariants', () => {
|
||||
reset.append('step/end', { turn: 1, step: 1 })
|
||||
reset.append('step/start', { turn: 1, step: 2 })
|
||||
expect(() => {
|
||||
reset.append('llm/retry', { turn: 1, step: 2, ...normal })
|
||||
reset.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
...normal,
|
||||
retryId: RetryId('reset-step-2-retry-chain'),
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps one retry identity per provider-policy chain', async () => {
|
||||
const ctx = await setup()
|
||||
const changed = openStep(ctx, 'retry-invariant-changed-chain-id')
|
||||
changed.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => changed.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
...normal,
|
||||
retry: 2,
|
||||
retryId: RetryId('changed-retry-chain'),
|
||||
})).toThrow(/must preserve retryId/)
|
||||
|
||||
const reused = openStep(ctx, 'retry-invariant-reused-chain-id')
|
||||
reused.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => reused.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
...always,
|
||||
retryId: normal.retryId,
|
||||
})).toThrow(/already owned by another chain/)
|
||||
})
|
||||
|
||||
it('validates retry-started correlation and uniqueness', async () => {
|
||||
const ctx = await setup()
|
||||
const empty = openStep(ctx, 'retry-started-empty-id')
|
||||
expect(() => empty.append('llm/retry-started', {
|
||||
retryId: RetryId(''), turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/retryId must be a non-empty string/)
|
||||
|
||||
const missing = openStep(ctx, 'retry-started-missing-schedule')
|
||||
expect(() => missing.append('llm/retry-started', {
|
||||
retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/pairs no prior scheduled attempt/)
|
||||
|
||||
const mismatch = openStep(ctx, 'retry-started-location-mismatch')
|
||||
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => mismatch.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 2, step: 1, retry: 1,
|
||||
})).toThrow(/turn\/step must match/)
|
||||
|
||||
const repeated = openStep(ctx, 'retry-started-repeated')
|
||||
repeated.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
repeated.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})
|
||||
expect(() => repeated.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/repeats one scheduled attempt/)
|
||||
})
|
||||
|
||||
it('starts a fresh retry chain after incomplete predecessor boundaries', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -261,4 +320,16 @@ describe('llm-retry invariants', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('accepts a scheduled and started attempt on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = openStep(ctx, 'retry-invariant-late-started')
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
session.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand'
|
||||
import type {} from '../src/index.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
|
||||
reason: 'initial',
|
||||
})
|
||||
const event = session.append('llm/retry', {
|
||||
retryId: RetryId(`retry-${kind}-chain`),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
|
||||
@@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
const event = await scheduled
|
||||
|
||||
expect(event.data.retryId).toEqual(expect.any(String))
|
||||
expect(event.data).toEqual({
|
||||
retryId: event.data.retryId,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compact/brand'
|
||||
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
|
||||
import {
|
||||
estimateContent,
|
||||
@@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
compactionId: CompactionId('context-breakdown-summary'),
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compact/brand'
|
||||
|
||||
const ZERO: TokenUsageProjection = {
|
||||
uncachedInputTokens: 0,
|
||||
@@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
compactionId: CompactionId('token-usage-summary'),
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
|
||||
@@ -277,8 +277,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>',
|
||||
jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */',
|
||||
signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise<CompactionResult | null>',
|
||||
jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @param sourceCommandId - initiating command identity for a manual compaction.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
|
||||
@@ -1841,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandInvocation',
|
||||
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandResult',
|
||||
@@ -1851,9 +1851,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CompactAgentContext',
|
||||
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'CompactionId',
|
||||
declaration: 'export type CompactionId = Branded<\'CompactionId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'CompactionResult',
|
||||
declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}',
|
||||
declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CompactionTrigger',
|
||||
@@ -3065,7 +3069,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolExecution',
|
||||
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
|
||||
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionFailure',
|
||||
@@ -3073,7 +3077,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionInput',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionMode',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compact/brand'
|
||||
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
type ReplayEntry,
|
||||
@@ -35,6 +36,8 @@ const TEXT_CHUNKS: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
const COMPACTION_ID = CompactionId('replay-compaction')
|
||||
|
||||
/** Build a minimal session-JSONL string: a header line + the given events. */
|
||||
function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string {
|
||||
const headerLine = JSON.stringify({
|
||||
@@ -194,12 +197,18 @@ describe('deriveReplayScript', () => {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = [
|
||||
...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)),
|
||||
{ type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } },
|
||||
{
|
||||
type: 'compact/start',
|
||||
seq: seq++,
|
||||
time: 0,
|
||||
data: { compactionId: COMPACTION_ID, turn: 1 },
|
||||
},
|
||||
{
|
||||
type: 'compact/summary',
|
||||
seq: seq++,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: rawOutput,
|
||||
rawOutput,
|
||||
llmStreamCall: true,
|
||||
@@ -227,6 +236,7 @@ describe('deriveReplayScript', () => {
|
||||
seq: 1,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: [{ type: 'text', text: 'template result' }],
|
||||
shadowedRange: { start: 1, end: 1 },
|
||||
shadowedSeqs: [1],
|
||||
@@ -246,6 +256,7 @@ describe('deriveReplayScript', () => {
|
||||
seq: 1,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: [block],
|
||||
rawOutput: [block],
|
||||
shadowedRange: { start: 1, end: 1 },
|
||||
@@ -267,6 +278,7 @@ describe('deriveReplayScript', () => {
|
||||
seq: 1,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: [{ type: 'text', text: 'missing source events' }],
|
||||
llmStreamCall: true,
|
||||
shadowedRange: { start: 1, end: 1 },
|
||||
@@ -289,6 +301,7 @@ describe('deriveReplayScript', () => {
|
||||
seq: 1,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: [block],
|
||||
rawOutput: [block],
|
||||
llmStreamCall: true,
|
||||
@@ -343,6 +356,7 @@ describe('deriveReplayScript', () => {
|
||||
seq: 2,
|
||||
time: 0,
|
||||
data: {
|
||||
compactionId: COMPACTION_ID,
|
||||
summary: [{ type: 'text', text: 'external checkpoint' }],
|
||||
shadowedRange: { start: 1, end: 1 },
|
||||
shadowedSeqs: [1],
|
||||
|
||||
Reference in New Issue
Block a user