refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/compaction/compaction/README.md
README.md: 5c2d9df07a24380ab93bd6b34dfa771b85211866
README.zh.md: b5a13cf47015867eb72981a64fc893a2605e5cd8
+93
View File
@@ -0,0 +1,93 @@
# @deepseek-ai/dsh-compaction
English | [中文](README.zh.md)
The **`CompactionEngine`** (`ctx.compaction`) defines WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
This package owns the Service Definition role of the compaction capability, split so each role evolves (and swaps) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compaction` (this) | Service Definition: abstract service + `compaction/*` events + `CompactionResult` + correlated checkpoint-source constructor + tool-pairing boundary helpers |
| `@deepseek-ai/dsh-compaction-basic` | Service provider: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-command-compact` | Consumer: the human `/compact` command over `ctx.compaction.compactNow()` |
Unlike the bash seam, this Service Definition depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "Service Definition depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compaction`)
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this Service Definition.
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compaction/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source comes from `compactCheckpointSource(compactionId)`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/subsystems/compaction.md#compactionresult).
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
## Tool-pairing boundaries
The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
## Surface contract
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, and `tool/result` may carry `surfaceOp`. A `compaction/*` event therefore **cannot** appear on the surface. A successful compaction instead:
1. appends `compaction/start` (log-only) — acquires the lock,
2. summarizes the range,
3. appends `compaction/summary` (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope,
4. appends a single `user/message` with `source: compactCheckpointSource(compactionId, sourceCommandId?)` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
5. appends `compaction/end` (log-only) — releases the lock.
The surface mutation (step 4) sits **inside** the lock bracket: `compaction/end` is the last event, so the lock is never released before the mutation lands. A crash between `compaction/start` and `compaction/end` therefore leaves a detectable orphaned lock (a `compaction/start` with no matching `compaction/end`) rather than a `compaction/end` that falsely claims compaction finished while the surface was never shadowed.
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
## Blocking
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compaction/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. A live bracket cannot cross a `turn/start` or `turn/end`; during adoption, repair boundaries in the inherited prefix remain replayable when the later end-seed proves their open bracket stale.
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compaction/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compaction/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
## Events
The `compaction/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md).
## Implementing a backend
Subclass `CompactionEngine`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compaction`. Every successful backend creates its replacement user message source with `compactCheckpointSource(compactionId, sourceCommandId?)`; the required `compactionId` correlates the checkpoint with its `compaction/*` transaction, while `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
`compactCheckpointSource()`, `CompactionCheckpointSource`, and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compaction/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The constructor requires the owning `CompactionId`, preventing backends from writing an uncorrelated marker that the package invariant must reject. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here.
## Model Experience
### Conversation history, when a backend is invoked
#### What the model sees
A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
#### Token effect
Zero direct tokens from this Service Definition. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
#### KV Cache effect
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
## Known Limitations and Deferred Work
- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.
@@ -0,0 +1,93 @@
# @deepseek-ai/dsh-compaction
[English](README.md) | 中文
**`CompactionEngine`**(`ctx.compaction`)定义压缩(compaction)做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。
本包承担压缩能力的 Service Definition 角色,因此各角色均可独立演进,也可独立替换:
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-compaction`(本包) | Service Definition:抽象服务 + `compaction/*` 事件 + `CompactionResult` + 关联检查点源构造函数 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compaction-basic` | Service provider:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-command-compact` | Consumer:面向人类的 `/compact` 命令,基于 `ctx.compaction.compactNow()` 实现 |
与 bash seam 不同,该 Service Definition 依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。约定的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「Service Definition 只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
## 服务 API(`ctx.compaction`)
三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本 Service Definition 的一部分。
| 成员 | 语义 |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop(智能体循环)步骤),因此每次调用都可在 `llm/stream` 处拦截。 |
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compaction/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源由 `compactCheckpointSource(compactionId)` 创建。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/subsystems/compaction.md#compactionresult)。
`compactIfNeeded` 和 `compactNow` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放)会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`。
`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence`。`changed` 和 `summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。
## 工具配对边界
该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于对齐和验证压缩边界。安全边界不会被尚未回答的 assistant 工具调用跨越。每个 helper 都会验证给定事件 seq 位于当前表层,并根据按表层顺序缓存的各切分点配对状态返回结果。
每个会话的私有 cache 以 `session.surface.replaceGeneration` 和已处理表层条目数为 key。generation 未变时,只需将尚未处理的尾部条目纳入累计结果;仅向日志追加、但未新增表层条目时,不会读取事件。replace generation 变化时则会重建当前成员关系与配对状态。事件 seq 缺失以及 `tool/result` 没有对应的先前未闭合调用,均会被视为表层状态损坏并遭拒绝。
## 表层约定
`SurfaceEventType` 是封闭联合:只有 `user/message`、`assistant/message` 和 `tool/result` 可以携带 `surfaceOp`。因此 `compaction/*` 事件**不能**出现在表层上。成功压缩改为:
1. 追加 `compaction/start`(仅日志):获取锁;
2. 摘要该范围;
3. 追加 `compaction/summary`(仅日志),其中记录摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope;
4. 追加单个 `user/message`,其携带 `source: compactCheckpointSource(compactionId, sourceCommandId?)` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**;
5. 追加 `compaction/end`(仅日志):释放锁。
表层变更(第 4 步)位于锁的起止范围**内**:`compaction/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compaction/start` 与 `compaction/end` 之间崩溃,会留下可检测的遗留锁(一个 `compaction/start` 没有匹配的 `compaction/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compaction/end`。
这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span,而不要求整个表层相等;位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。
`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。
## 阻塞
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compaction/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。活动标记对不能跨越 `turn/start` 或 `turn/end`;在接管会话时,如果后续 end-seed 证明打开的标记对已经陈旧,则继承前缀中的修复边界仍可回放。
锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compaction/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compaction/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed` 或 `summary` 也会 flush,从而在释放轮次接纳预留前保留该记录。
## 事件
`compaction/*` 事件通过 declaration merging 扩展 `SessionEventMap`(可合并扩展):它们是会话事件,不是 cordis `Events`,三者均仅存在于日志(不含 `surfaceOp`)。各事件 payload 与语义见生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)。
## 实现后端
继承 `CompactionEngine`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compaction`。每个成功后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换 user 消息的源;必填的 `compactionId` 将检查点与对应 `compaction/*` 事务关联,而 `isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`)
`compactCheckpointSource()`、`CompactionCheckpointSource` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compaction/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。构造函数要求传入所属 `CompactionId`,防止后端写入缺少关联关系、必然被包不变量拒绝的标记。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的 transcript(文本记录)适配器用仅类型导入把它的插件字面量钉在该叶子的源类型上,因此在此处改插件 id 会让那边编译失败。
## 模型体验
### 调用后端时的会话历史
#### 模型看到的内容
成功的实现会用一个 user 角色摘要检查点替换较早表层范围,即一个 `user/message`,它携带 `surfaceOp: { op: 'replace', start, end }`;原始事件仍会记录,但不再出现在派生模型消息中。seam 本身不执行改写。
#### Token 影响
该 Service Definition 不会直接产生 token。后端用一份摘要换取多个原本保留的历史 token,并保持近期尾部不变。
#### KV Cache 影响
成功的后端替换会使从第一个已遮蔽历史 token 起的复用失效;seam 本身不会改变请求。
## 已知限制与暂缓事项
- **面向用户的命令,而非模型工具**:`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具。
- **部分单元溢出不在约定内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。
@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-compaction",
"description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/compaction/compaction"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./checkpoint": {
"types": "./lib/types/checkpoint.d.ts",
"default": "./lib/types/checkpoint.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -0,0 +1,13 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */
export type CompactionId = Branded<'CompactionId'>
/**
* Brand an implementation-minted compaction identity.
* @param id - opaque transaction identity.
* @returns the same string, branded; no validation is performed.
*/
export function CompactionId(id: string): CompactionId {
return id as CompactionId
}
@@ -0,0 +1,51 @@
/**
* Compaction checkpoint provenance: the correlated source constructor and type
* every backend uses for its replacement user message, plus the predicate that
* recognizes persisted checkpoints.
*
* The seam itself lives in `@deepseek-ai/dsh-compaction`, which re-exports these
* contracts; this module is a pure type/value/predicate outlet (no cordis
* imports, no module augmentation) so client and wire programs can name the
* checkpoint source without loading the host plugin's Context merges — the
* `dsh-commands/brand` shape.
*
* @module @deepseek-ai/dsh-compaction/checkpoint
*/
import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/** Message provenance carried by a concrete compaction checkpoint. */
export type CompactionCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & {
readonly compactionId: CompactionId
readonly sourceCommandId?: CommandId
}
/**
* Create checkpoint provenance correlated with one compaction transaction.
* @param compactionId - owning compaction identity.
* @param sourceCommandId - initiating manual command, when present.
* @returns immutable checkpoint source.
*/
export function compactCheckpointSource(
compactionId: CompactionId,
sourceCommandId?: CommandId,
): CompactionCheckpointSource {
return Object.freeze({
...COMPACT_CHECKPOINT_MARKER,
compactionId,
...sourceCommandId === undefined ? {} : { sourceCommandId },
})
}
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Compaction Service Definition (`ctx.compaction`): providers decide when to
* compact and replace a history range with one summary node by subclassing
* {@link CompactionEngine}. This interface necessarily depends on session and LLM
* vocabulary; the rationale is in the
* [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
* @module @deepseek-ai/dsh-compaction
*/
import { Context, Service } from '@deepseek-ai/cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { CompactionId } from './brand.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
// The checkpoint source constructor and predicate are declared on the cordis-free
// `./checkpoint` leaf so client and wire programs can name them without this
// root's Context merge; the root stays the host-side entry point for both.
export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts'
export type { CompactionCheckpointSource } from './checkpoint.ts'
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Expected failure classes for an explicit idle-session compaction request. */
export type ManualCompactionErrorCode =
| 'busy'
| 'cancelled'
| 'changed'
| 'summary'
| 'commit'
| 'persistence'
/**
* Expected manual-compaction failure suitable for a direct human-command result.
* Shared durable-lock entry assertions may also throw the `busy` subtype from
* automatic compaction paths.
*/
export class ManualCompactionError extends Error {
override readonly name = 'ManualCompactionError'
/**
* Create one classified compaction failure.
* @param code - stable failure class; `busy` may originate from any compaction entry path.
* @param message - backend diagnostic retained as the Error message.
* @param options - optional original failure.
*/
constructor(
readonly code: ManualCompactionErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactionAgentContext {
session: Session
options: { provider?: string; model?: string }
}
/**
* Agent capability required to serialize an explicit idle-session compaction
* against driver turns. The durable `compaction/start` marker separately excludes
* other compaction transactions.
*/
export interface ManualCompactAgentContext extends CompactionAgentContext {
/**
* Run a non-turn maintenance operation only while the agent is idle, withholding later
* waking input until it settles.
* @param task - operation whose fulfillment or rejection is preserved, with an agent-owned cancellation signal.
* @throws synchronously when the agent is already active.
* @returns the task promise.
*/
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
}
declare module '@deepseek-ai/cordis' {
interface Context {
compaction: CompactionEngine
}
}
/**
* Abstract compaction service. Implementations own trigger policy, retention,
* and summarization, and may consume a separate measurement service. A
* successful run replaces the selected surface span with one summary node and
* prevents concurrent compaction of the same session. The replacement user
* message uses {@link compactCheckpointSource} with the transaction identity
* so consumers recognize and correlate it independently of the backend. Load
* one implementation per context as `ctx.compaction`.
*/
export abstract class CompactionEngine extends Service {
constructor(ctx: Context) {
super(ctx, 'compaction')
}
/**
* Consider automatic compaction for one explicit trigger. Pressure policy
* uses the latest durable routed request, while context-overflow policy may
* force a useful balanced reduction even below the normal threshold. Return
* `null` when no safe range can be compacted. A single oversized retained
* unit or request envelope cannot be repaired through surface compaction.
*
* @param agent - agent context owning the session surface and routing options.
* @param trigger - normal pressure or provider-confirmed context overflow.
* @param signal - cancellation signal; model-backed implementations must forward it.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
agent: CompactionAgentContext,
trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Explicitly compact useful history even below automatic pressure thresholds.
* Implementations synchronously start an idle task before any asynchronous
* work, select a useful range without writing on a no-op, then
* append a standalone `compaction/start` before summarization. That durable
* marker is the compaction lock until one `compaction/end` attempt. Later waking
* prompts remain accepted in FIFO order and start only after the optional
* durability checkpoint and idle-task settlement. Context injected while the
* summary runs may sit between the marker pair; only the selected span must
* remain stable.
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for a manual compaction.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
* changed-span, summarization/shrink, commit-stage, or persistence failures;
* an aborted request preserves its exact abort reason. Failed attempts remain
* visible in the log.
*/
abstract compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
sourceCommandId?: CommandId,
): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq
* order; replacements can make visible seqs non-monotonic. Both edges must be
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges. The target session is `agent.session`.
* Its replacement user message must use {@link compactCheckpointSource} with
* the transaction's `CompactionId`.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*
* @param start - first surface seq, inclusive.
* @param end - last surface seq, inclusive.
* @param agent - context whose session is mutated and whose routing options guide summarization.
* @param signal - optional cancellation; model-backed implementations must forward it.
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
* @returns the appended event seqs, summary, replaced range, and token accounting.
*/
abstract compactRegion(
start: number,
end: number,
agent: CompactionAgentContext,
signal?: AbortSignal,
): Promise<CompactionResult>
}
export default CompactionEngine
@@ -0,0 +1,306 @@
/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compaction/invariant */
import type { Context } from '@deepseek-ai/cordis'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { CompactionId } from './brand.ts'
import { isCompactCheckpointSource } from './checkpoint.ts'
import type { CompactionCheckpointSource } from './checkpoint.ts'
import type {} from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-compaction'
/** Cordis companion plugin name. */
export const name = 'compaction-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
interface CompactionTrace {
compactionId: CompactionId
sourceCommandId: string | undefined
startSeq: number
turn: number | null
summarized: boolean
}
interface SessionTrace {
openTurn: number | null
compaction: CompactionTrace | undefined
}
type CompactionTransition =
| { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'end' }
| { kind: 'end-seed' }
/** Require a durable opaque identity to be a non-empty string. */
function validateId(value: unknown, label: string, fail: InvariantFailure): asserts value is string {
if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`)
}
/** Keep the optional initiating command identity stable across one transaction. */
function validateSourceCommandId(
eventType: string,
value: unknown,
expected: string | undefined,
fail: InvariantFailure,
): void {
if (value !== undefined) validateId(value, `${eventType} sourceCommandId`, fail)
if (value !== expected) {
fail(`${eventType} sourceCommandId ${String(value)} does not match compaction/start sourceCommandId ${String(expected)}`)
}
}
/** Validate one replacement checkpoint against its open compaction transaction. */
function validateCheckpoint(
trace: SessionTrace,
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const source = event.data.source as typeof event.data.source & Partial<CompactionCheckpointSource>
validateId(source.compactionId, 'compaction checkpoint compactionId', fail)
if (source.sourceCommandId !== undefined) {
validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail)
}
const open = trace.compaction
if (open === undefined) fail('compaction checkpoint has no matching compaction/start')
if (source.compactionId !== open.compactionId) {
fail(`compaction checkpoint id ${source.compactionId} does not match compaction/start id ${open.compactionId}`)
}
validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail)
}
/** Compaction starts still unmatched when a later seed boundary made them stale. */
function inheritedOrphanStartSeqs(
events: readonly SessionEvent[],
): ReadonlySet<number> {
const stale = new Set<number>()
let openStartSeq: number | undefined
for (const event of events) {
if (event.type === 'compaction/start') {
openStartSeq = event.seq
} else if (event.type === 'compaction/end') {
openStartSeq = undefined
} else if (event.type === 'session/end-seed') {
if (openStartSeq !== undefined) stale.add(openStartSeq)
openStartSeq = undefined
}
}
return stale
}
/** Keep every live compaction bracket on one side of each turn boundary. */
function validateTurnBoundary(
trace: SessionTrace,
event: SessionEvent,
fail: InvariantFailure,
): void {
if (
(event.type !== 'turn/start' && event.type !== 'turn/end')
|| trace.compaction === undefined
) return
const owner = trace.compaction.turn === null
? 'standalone compaction'
: `compaction for turn ${trace.compaction.turn}`
fail(`${event.type} cannot cross an open ${owner}`)
}
/** Advance the committed turn cursor after its boundary has been accepted. */
function applyTurnBoundary(trace: SessionTrace, event: SessionEvent): boolean {
if (event.type === 'turn/start') {
trace.openTurn = event.data.turn
return true
}
if (event.type === 'turn/end') {
trace.openTurn = null
return true
}
return false
}
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
function validateOwner(
owner: number | null,
openTurn: number | null,
eventType: 'compaction/start' | 'compaction/summary' | 'compaction/end',
fail: InvariantFailure,
): void {
if (owner === null) {
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`)
return
}
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
}
/** Validate one compaction event without advancing committed trace state. */
function validateCompactionEvent(
trace: SessionTrace,
event: SessionEvent,
fail: InvariantFailure,
): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type === 'user/message'
&& isReplacementSurfaceEvent(event)
&& isCompactCheckpointSource(event.data.source)) {
validateCheckpoint(trace, event, fail)
return undefined
}
if (event.type !== 'compaction/start' && event.type !== 'compaction/summary' && event.type !== 'compaction/end') {
return undefined
}
const open = trace.compaction
if (event.type === 'compaction/start') {
validateId(event.data.compactionId, 'compaction/start compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compaction/start sourceCommandId', fail)
}
if (open !== undefined) {
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
fail(`compaction/start while ${owner} is still compacting`)
}
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
return {
kind: 'start',
compactionId: event.data.compactionId,
sourceCommandId: event.data.sourceCommandId,
startSeq: event.seq,
turn: event.data.turn,
}
}
if (event.type === 'compaction/summary') {
validateId(event.data.compactionId, 'compaction/summary compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compaction/summary sourceCommandId', fail)
}
if (open === undefined) fail('compaction/summary has no matching compaction/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compaction/summary id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`)
}
validateSourceCommandId('compaction/summary', event.data.sourceCommandId, open.sourceCommandId, fail)
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compaction/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs
if (seqs.length === 0) fail('compaction/summary shadowedSeqs must be non-empty')
if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) {
fail('compaction/summary shadowedRange must match the first and last shadowedSeqs')
}
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
fail('compaction/summary shadowedTokenCount must be a non-negative safe integer')
}
return {
kind: 'summary',
compactionId: open.compactionId,
sourceCommandId: open.sourceCommandId,
startSeq: open.startSeq,
turn: open.turn,
}
}
validateId(event.data.compactionId, 'compaction/end compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compaction/end sourceCommandId', fail)
}
if (open === undefined) fail('compaction/end has no matching compaction/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compaction/end id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`)
}
validateSourceCommandId('compaction/end', event.data.sourceCommandId, open.sourceCommandId, fail)
if (event.data.turn !== open.turn) {
fail(`compaction/end owner ${String(event.data.turn)} does not match compaction/start owner ${String(open.turn)}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (event.data.error === undefined && !open.summarized) {
fail('successful compaction/end requires one compaction/summary')
}
return { kind: 'end' }
}
/** Apply one committed compaction transition. */
function applyCompactionTransition(
transition: CompactionTransition,
): CompactionTrace | undefined {
if (transition.kind === 'start') {
return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq,
turn: transition.turn,
summarized: false,
}
}
if (transition.kind === 'summary') {
return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq,
turn: transition.turn,
summarized: true,
}
}
return undefined
}
/** Install compaction start/summary/end checks. */
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
/* jscpd:ignore-start */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, SessionTrace>()
const staged = new WeakMap<SessionEvent, { session: Session; transition: CompactionTransition }>()
const seed = (session: Session): SessionTrace => {
const trace: SessionTrace = { openTurn: null, compaction: undefined }
traces.set(session, trace)
const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events)
for (const event of session.events) {
// Constructor-seed repair boundaries can precede the end-seed marker
// that proves an inherited orphan stale. Replay that inherited prefix
// without letting the soon-to-be-cleared bracket veto its repair.
if (
trace.compaction === undefined
|| !staleOrphanStartSeqs.has(trace.compaction.startSeq)
) {
validateTurnBoundary(trace, event, fail)
}
const transition = validateCompactionEvent(trace, event, fail)
if (transition !== undefined) trace.compaction = applyCompactionTransition(transition)
applyTurnBoundary(trace, event)
}
return trace
}
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => {
const trace = traceFor(session)
validateTurnBoundary(trace, event, fail)
if (applyTurnBoundary(trace, event)) return
if (event.type !== 'session/end-seed'
&& event.type !== 'compaction/start'
&& event.type !== 'compaction/summary'
&& event.type !== 'compaction/end') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every compaction event */
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
staged.delete(event)
trace.compaction = applyCompactionTransition(candidate.transition)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const trace = traceFor(session)
validateTurnBoundary(trace, event, fail)
const transition = validateCompactionEvent(trace, event, fail)
if (transition !== undefined) staged.set(event, { session, transition })
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the compact invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,131 @@
/**
* Tool-pairing balance over a session surface. Compaction changes surface
* positions, so safe cuts are derived from tool-call/result content in current
* surface order rather than step markers.
* @module @deepseek-ai/dsh-compaction/tool-pairing
*/
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
/** Incremental balance state for one session surface generation. */
interface BalanceCache {
/** Surface rewrite generation this state describes. */
generation: number
/**
* Balance of every surface cut in current order: a surface of N sequences has
* N + 1 cuts, entry `i` being the cut before sequence `i` and the final entry
* the cut after the surface tail.
*/
cutBalanced: readonly boolean[]
/** Current surface position of each event seq, indexing {@link cutBalanced}. */
indexBySeq: Map<number, number>
/** In-progress tool-call count after the processed surface tail. */
inProgressToolCalls: number
}
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
/** Return how one surface event changes the in-progress tool-call count. */
function eventDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.message.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
default:
return 0
}
}
/** Read and validate the event named by a surface sequence. */
function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent {
const event = events[seq]
if (event === undefined || event.seq !== seq) {
throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
}
return event
}
/** Fold surface sequences not yet in the cache into its balance state. */
function extendCache(
session: Session,
cache: BalanceCache,
seqs: readonly number[],
): BalanceCache {
const processed = cache.cutBalanced.length - 1
const tail = seqs.slice(processed)
// Validate the unseen tail before mutating the live cache, so a corrupt
// append cannot leave a partially advanced state behind.
const events = session.events
const pendingCuts: boolean[] = []
let inProgressToolCalls = cache.inProgressToolCalls
for (const seq of tail) {
inProgressToolCalls += eventDelta(eventForSeq(events, seq))
if (inProgressToolCalls < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
}
pendingCuts.push(inProgressToolCalls === 0)
}
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
cache.inProgressToolCalls = inProgressToolCalls
return cache
}
/** Return balance state synchronized with the current session surface. */
function balanceCache(session: Session): BalanceCache {
const surface = session.surface
const seqs = surface.nodes
const generation = surface.replaceGeneration
const cached = balanceCacheBySession.get(session)
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
// A rebuild is the same fold started from the empty-surface state, whose
// single leading cut is trivially balanced.
const rebuilt = extendCache(session, {
generation,
cutBalanced: [true],
indexBySeq: new Map(),
inProgressToolCalls: 0,
}, seqs)
balanceCacheBySession.set(session, rebuilt)
return rebuilt
}
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
return cached
}
/** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
const index = cache.indexBySeq.get(seq)
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
}
return balanced
}
/**
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param seq - event sequence whose leading cut is checked.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface sequence has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedBefore(session: Session, seq: number): boolean {
return cutBalance(balanceCache(session), seq, 0)
}
/**
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param seq - event sequence whose trailing cut is checked.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface sequence has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedAfter(session: Session, seq: number): boolean {
return cutBalance(balanceCache(session), seq, 1)
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Compaction vocabulary: the result type and the `compaction/*` session events.
* Those declaration-merged events record the lock and summary inputs without entering the surface, so they are not
* surface events; a separate replacement `user/message` carries the summary.
* Backend packages own configuration and retention policy; see
* `.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md`.
* @module @deepseek-ai/dsh-compaction/types
*/
import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
export type { CompactionId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Marks the start of a compaction — log-only, holds the lock until
* `compaction/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compaction/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null }
/**
* Completed summary, its inputs, and its model call facts — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
* is performed by the immediately following `user/message` event that
* shadows the compacted range. That adjacency is contractual — the
* shadowed pricing fields are the replacement's shadow price, so a
* consumer may pair a replacement with the metering event directly
* before it (`compaction/prune` documents the shared protocol).
*/
'compaction/summary': {
compactionId: CompactionId
sourceCommandId?: CommandId
summary: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
/** The provider route that wrote the summary. */
provider: string
/**
* The model that wrote the summary — the summarize call's envelope,
* reported by the backend that made the call, logged so the one-shot
* request is reconstructable from log + code and "which model wrote
* this summary" has a durable answer (the reconstructability Agent Note).
*/
model: string
/** The generation cap the summarize call sent, when one applied. */
maxTokens?: number
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
} & (
| {
/** Complete provider output before the backend's safe summary projection. */
rawOutput: ContentBlock[]
/** Identifies exactly one call through this context's `ctx.llm.stream()`. */
llmStreamCall: true
}
| {
/** Optional complete output from an unmarked template, remote, or other summarizer. */
rawOutput?: ContentBlock[]
/** An unmarked summary does not identify a call through this context's LLM seam. */
llmStreamCall?: never
}
)
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compaction/start`; `error` records an unsuccessful attempt.
*/
'compaction/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string }
/**
* Shadow price of one model-free prune replacement — log-only, no
* surfaceOp. The shared shadow-price protocol: a surface `replace` event
* is priced by the metering event immediately before it (`compaction/summary`
* for a summarizing compaction, this event for a prune), which states the
* heuristic token price of the exact replaced range so a pure consumer
* can subtract it without retaining per-node prices. The replacement MUST
* be appended synchronously right after this event.
*/
'compaction/prune': {
/** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */
shadowedRange: { start: number; end: number }
/** The seqs of all shadowed surface nodes, in surface order. */
shadowedSeqs: number[]
/** Heuristic price of the shadowed content under the token-meter's fixed estimator. */
shadowedTokenCount: number
}
}
}
/** Result of a successful compaction operation. */
export interface CompactionResult {
/** Stable identity shared by this compaction's complete durable lifecycle. */
compactionId: CompactionId
/** Human command that initiated this compaction, when it was manual. */
sourceCommandId?: CommandId
/** The seq of the appended `compaction/start` event. */
startSeq: number
/** The seq of the appended `compaction/summary` event. */
summarySeq: number
/** The seq of the appended `compaction/end` event. */
endSeq: number
/** The summary content blocks produced by the backend. */
summary: ContentBlock[]
/**
* The surface-boundary pair that was shadowed: the seqs of the first
* (`start`) and last (`end`) surface nodes of the replaced range. A
* surface-POSITION span, not a numeric seq interval — after a prior replace
* lands a fresh high-seq summary node at an older range's position, `start`
* can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the
* authoritative set of shadowed nodes, in surface order.
*/
shadowedRange: { start: number; end: number }
/** The seqs of all shadowed surface nodes, in surface order. */
shadowedSeqs: number[]
/** Estimated token count of the shadowed content. */
shadowedTokenCount: number
}
@@ -0,0 +1,170 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import {
CompactionId,
CompactionEngine,
compactCheckpointSource,
isCompactCheckpointSource,
} from '@deepseek-ai/dsh-compaction'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactionAgentContext } from '@deepseek-ai/dsh-compaction'
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compaction'
/**
* A trivial concrete CompactionEngine implementing the abstract contract. The
* Service Definition package owns no algorithm — these tests exercise its contract:
* service registration, the abstract method shape, and the `compaction/*` event
* declaration merge.
*/
class StubCompactionEngine extends CompactionEngine {
/** Records the signal handed to the most recent call, to prove it threads through. */
lastSignal: AbortSignal | undefined
override async compactIfNeeded(
_agent: CompactionAgentContext,
_trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
return null
}
override async compactNow(
_agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
return null
}
override async compactRegion(
start: number,
end: number,
agent: CompactionAgentContext,
signal?: AbortSignal,
): Promise<CompactionResult> {
this.lastSignal = signal
const session = agent.session
const summary = [{ type: 'text' as const, text: 'stub' }]
const surface = session.surface.nodes
const startIndex = surface.indexOf(start)
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('compaction/start', { compactionId, turn: 0 })
const summaryEvent = session.append('compaction/summary', {
compactionId,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount: 0,
provider: 'mock',
model: 'stub',
})
session.append('user/message', createUserMessage({
content: summary,
source: compactCheckpointSource(compactionId),
}), {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compaction/end', { compactionId, turn: 0 })
return {
compactionId,
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount: 0,
}
}
}
describe('CompactionEngine seam', () => {
function stubAgent(session: Session, model?: string): CompactionAgentContext {
return { session, options: model === undefined ? {} : { model } }
}
it('registers as ctx.compaction', () => {
const ctx = new Context()
void new StubCompactionEngine(ctx)
expect(ctx.compaction).toBeDefined()
expect(ctx.compaction).toBeInstanceOf(StubCompactionEngine)
})
it('disposing the fiber unregisters ctx.compaction (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(StubCompactionEngine)
expect(ctx.compaction).toBeInstanceOf(StubCompactionEngine)
await fiber.dispose()
expect(ctx.compaction).toBeUndefined()
})
it('exposes the abstract contract methods', async () => {
const ctx = new Context()
const svc = new StubCompactionEngine(ctx)
const session = Session.create(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
const signal = new AbortController().signal
expect(await svc.compactNow({
...stubAgent(session),
runMaintenance: task => task(new AbortController().signal),
}, signal)).toBeNull()
expect(svc.lastSignal).toBe(signal)
})
it('compaction/* events merge into SessionEventMap and are log-only', async () => {
const ctx = new Context()
const svc = new StubCompactionEngine(ctx)
const session = Session.create(SessionId('s'))
const original = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'))
const startEvent = session.events.find(e => e.type === 'compaction/start')
expect(startEvent).toBeDefined()
// Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType);
// verify the runtime value is absent.
const raw = startEvent as unknown as { surfaceOp?: unknown }
expect(raw.surfaceOp).toBeUndefined()
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq })
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(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('compaction/')).map(e => e.type))
.toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
})
it('threads the cancellation signal through to the backend', async () => {
const ctx = new Context()
const svc = new StubCompactionEngine(ctx)
const session = Session.create(SessionId('s'))
const controller = new AbortController()
const original = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'), controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
})
})
@@ -0,0 +1,391 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import * as CompactionInvariant from '@deepseek-ai/dsh-compaction/invariant'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(CompactionInvariant)
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],
shadowedTokenCount: 12,
provider: 'mock',
model: 'mock',
...overrides,
})
function startTurn(session: ReturnType<Context['sessions']['create']>, turn = 1): void {
session.append('turn/start', { turn })
}
describe('compaction invariants', () => {
it('accepts successful and failed compaction lifecycles', async () => {
const ctx = await setup()
const success = ctx.sessions.create()
startTurn(success)
success.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
success.append('compaction/summary', summary())
success.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
const failed = ctx.sessions.create()
startTurn(failed, 2)
failed.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })
failed.append('compaction/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('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
success.append('compaction/summary', summary())
success.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null })
const failed = ctx.sessions.create()
failed.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
failed.append('compaction/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('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['compaction/start', 'session/end-seed'])
await ctx.plugin(InvariantRegistry)
await ctx.plugin(CompactionInvariant)
expect(() => {
replayed.append('compaction/start', { compactionId: NEXT_COMPACTION_ID, turn: null })
replayed.append('compaction/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' })
}).not.toThrow()
})
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('stale-numbered-compaction-source'))
startTurn(source)
source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['turn/start', 'compaction/start', 'session/end-seed'])
await ctx.plugin(InvariantRegistry)
await ctx.plugin(CompactionInvariant)
expect(() => replayed.append(
'turn/end',
{ turn: 1, reason: { kind: 'interrupted' } },
)).not.toThrow()
})
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('stale-repaired-compaction-source'))
source.append('compaction/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'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type)).toEqual([
'compaction/start',
'turn/start',
'turn/end',
'session/end-seed',
])
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(CompactionInvariant).then(() => undefined)).resolves.toBeUndefined()
expect(() => {
startTurn(replayed, 2)
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('closed-nested-compaction-source'))
source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
source.append('compaction/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,
})
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(CompactionInvariant).then(() => undefined))
.rejects.toThrow(/turn\/start cannot cross an open standalone compaction/)
})
it('rebuilds an open trace when the companion loads after the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1 })
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
await ctx.plugin(InvariantRegistry)
await ctx.plugin(CompactionInvariant)
expect(() => session.append('compaction/end', {
compactionId: TEST_COMPACTION_ID,
turn: 1,
error: 'resume failed',
})).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
it('adopts a bare session and ignores unrelated committed events', async () => {
const ctx = await setup()
const session = Session.create(SessionId('bare-compaction-session'))
expect(() => {
ctx.emit('session/event', session, {
type: 'turn/start', seq: 0, time: 0,
data: { turn: 1 },
})
ctx.emit('session/event', session, {
type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 },
})
ctx.emit('session/event', session, {
type: 'compaction/start', seq: 2, time: 2,
data: { compactionId: TEST_COMPACTION_ID, turn: 1 },
})
}).not.toThrow()
})
it('rejects compaction outside or for a different open turn', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
expect(() => session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }))
.toThrow(/outside any open turn/)
startTurn(session)
expect(() => session.append('compaction/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('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null }))
.toThrow(/standalone but turn 1 is open/)
const idle = ctx.sessions.create()
expect(() => idle.append('compaction/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('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
expect(() => session.append('compaction/start', { compactionId: NEXT_COMPACTION_ID, turn: null }))
.toThrow(/standalone compaction is still compacting/)
})
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
startTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(CompactionInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
const ctx = await setup()
const standalone = ctx.sessions.create()
standalone.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
expect(() => { startTurn(standalone) })
.toThrow(/turn\/start cannot cross an open standalone compaction/)
standalone.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' })
expect(() => {
startTurn(standalone)
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
const numbered = ctx.sessions.create()
startTurn(numbered)
numbered.append('compaction/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('compaction/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('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/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 compaction\/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 compaction\/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('compaction/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('compaction/start', { compactionId: CompactionId(''), turn: 1 })
}, /compaction\/start compactionId must be a non-empty string/],
['empty start source command id', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: CommandId(''),
turn: 1,
})
}, /compaction\/start sourceCommandId must be a non-empty string/],
['summary without start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/summary', summary())
}, /no matching compaction\/start/],
['nested start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 })
}, /still compacting/],
['repeated summary', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/summary', summary())
session.append('compaction/summary', summary())
}, /repeated within one compaction/],
['summary for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/summary', summary({ compactionId: NEXT_COMPACTION_ID }))
}, /compaction\/summary id .* does not match compaction\/start id/],
['summary for another source command', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compaction/summary', summary({ sourceCommandId: NEXT_COMMAND_ID }))
}, /compaction\/summary sourceCommandId .* does not match compaction\/start sourceCommandId/],
['empty shadow set', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/summary', summary({ shadowedSeqs: [] }))
}, /shadowedSeqs must be non-empty/],
['wrong endpoints', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/summary', summary({ shadowedRange: { start: 1, end: 4 } }))
}, /shadowedRange must match/],
['invalid token count', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/summary', summary({ shadowedTokenCount: -1 }))
}, /non-negative safe integer/],
['end without start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
}, /no matching compaction\/start/],
['wrong end turn', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' })
}, /does not match/],
['end for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' })
}, /compaction\/end id .* does not match compaction\/start id/],
['end missing the source command', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
}, /compaction\/end sourceCommandId .* does not match compaction\/start sourceCommandId/],
['empty end source command id', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compaction/end', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: CommandId(''),
turn: 1,
error: 'failed',
})
}, /compaction\/end sourceCommandId must be a non-empty string/],
['success without summary', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
}, /requires one compaction\/summary/],
])('rejects %s', async (_name, action, message) => {
const ctx = await setup()
const session = ctx.sessions.create()
startTurn(session)
expect(() => { action(session) }).toThrow(message)
})
})
@@ -0,0 +1,424 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const SURFACE = { surfaceOp: 'append' as const }
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
return session.events.filter(event => event.type === type)[nth]!.seq
}
function surfaceSeq(session: Session, seq: number): number {
const current = session.surface.nodes.find(candidate => candidate === seq)
if (current === undefined) throw new Error(`seq ${seq} is not on the surface`)
return current
}
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedBefore(session, surfaceSeq(session, seqOf(session, type, nth)))
}
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
}
function closedToolStep(): Session {
const session = Session.create(SessionId('closed-tool-step'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}), SURFACE)
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, SURFACE)
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'done' }],
isError: false,
}),
}, SURFACE)
return session
}
describe('tool-pairing boundaries', () => {
it('classifies closed and open single-call steps', () => {
const closed = closedToolStep()
expect(before(closed, 'user/message')).toBe(true)
expect(after(closed, 'user/message')).toBe(true)
expect(before(closed, 'assistant/message')).toBe(true)
expect(after(closed, 'assistant/message')).toBe(false)
expect(before(closed, 'tool/result')).toBe(false)
expect(after(closed, 'tool/result')).toBe(true)
const open = Session.create(SessionId('open-tool-step'))
open.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, SURFACE)
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
})
it('requires every result from a multiple-call assistant message', () => {
const session = Session.create(SessionId('multiple-calls'))
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c2'),
content: [],
isError: false,
}),
}, SURFACE)
expect(after(session, 'tool/result', 0)).toBe(false)
expect(after(session, 'tool/result', 1)).toBe(true)
})
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
const midStep = Session.create(SessionId('neutral-mid-step'))
midStep.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, SURFACE)
midStep.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'background update' }],
source: { kind: 'plugin', plugin: 'test' },
}), SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, SURFACE)
expect(before(midStep, 'user/message')).toBe(false)
expect(after(midStep, 'user/message')).toBe(false)
const free = Session.create(SessionId('neutral-free'))
free.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
}), SURFACE)
expect(before(free, 'user/message')).toBe(true)
expect(after(free, 'user/message')).toBe(true)
})
})
describe('tool-pairing surface identity', () => {
it('rebuilds after replace and rejects sequences removed from current membership', () => {
const session = closedToolStep()
const staleTail = surfaceSeq(session, seqOf(session, 'tool/result'))
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
const nodes = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
sourceEventSeqs: [...nodes],
})
const checkpoint = session.surface.nodes[0]!
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('answers repeated queries from cached balances', () => {
const session = closedToolStep()
const assistant = surfaceSeq(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
})
it('rejects missing seqs before and after, including an empty surface', () => {
const session = Session.create(SessionId('missing-membership'))
const missing = 999
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first node after empty cache' }],
source: { kind: 'user' },
}), SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
})
})
describe('tool-pairing cache refresh', () => {
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: createUserMessage({
content: [{ type: 'text', text: 'user' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
},
{
type: 'assistant/message', seq: 1, time: 1,
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 2, time: 2,
data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
},
surfaceOp: 'append',
},
]
const nodes: number[] = [0, 1, 2]
let generation = 0
let eventCollectionReads = 0
let eventIndexReads = 0
const trackedEvents = new Proxy(events, {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
return Reflect.get(target, property, receiver) as unknown
},
})
const surface = {
get nodes() { return nodes },
get replaceGeneration() { return generation },
}
const session = {
surface,
get events() {
eventCollectionReads += 1
return trackedEvents
},
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } },
})
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'user/message', seq: 4, time: 4,
data: createUserMessage({
content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
})
nodes.push(4)
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
expect(eventCollectionReads).toBe(2)
expect(eventIndexReads).toBe(4)
events.push(
{
type: 'assistant/message', seq: 5, time: 5,
data: {
turn: 2,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 6, time: 6,
data: {
turn: 2, step: 1,
message: createToolResultMessage({
callId: CallId('c2'),
content: [],
isError: false,
}),
},
surfaceOp: 'append',
},
)
nodes.push(5, 6)
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
expect(eventCollectionReads).toBe(3)
expect(eventIndexReads).toBe(6)
events.push({
type: 'user/message', seq: 7, time: 7,
data: createUserMessage({
content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' },
}),
surfaceOp: { op: 'replace', start: 0, end: 6 },
})
nodes.splice(0, nodes.length, 7)
generation += 1
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
expect(eventCollectionReads).toBe(4)
expect(eventIndexReads).toBe(7)
})
it('rebuilds defensively when a same-generation surface entry count regresses', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: createUserMessage({
content: [], source: { kind: 'user' },
}), surfaceOp: 'append',
},
{
type: 'user/message', seq: 1, time: 1,
data: createUserMessage({
content: [], source: { kind: 'user' },
}), surfaceOp: 'append',
},
]
const nodes: number[] = [0, 1]
const session = {
events,
surface: { nodes, replaceGeneration: 0 },
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
nodes.pop()
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
})
})
describe('tool-pairing corrupt surfaces', () => {
it('throws for an orphan result during a rebuild', () => {
const session = Session.create(SessionId('orphan-rebuild'))
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('orphan'),
content: [],
isError: false,
}),
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
})
it('retries an orphan result in an appended tail without committing partial cache state', () => {
const session = Session.create(SessionId('orphan-tail'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
}), SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('orphan'),
content: [],
isError: false,
}),
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
})
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
const missingSeq = 1
const missing = {
events: [{
type: 'user/message', seq: 0, time: 0,
data: createUserMessage({
content: [], source: { kind: 'user' },
}), surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [missingSeq], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
const mismatchedSeq = 0
const mismatched = {
events: [{
type: 'user/message', seq: 99, time: 0,
data: createUserMessage({
content: [], source: { kind: 'user' },
}), surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../core/session"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown'
/** Builds each published entry as a self-contained file admitted by the package whitelist. */
export default defineConfig([
{
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
])