From e45053f0f51d8da29a534c5f52ff9bb42d1e2565 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 22 Jun 2026 14:52:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(compact):=20compaction=20capability=20seam?= =?UTF-8?q?=20=E2=80=94=20abstract=20CompactService=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the @deepseek-ai/dsh-compact interface package: the abstract CompactService (ctx.compact) with compactIfNeeded / compactRegion, the compact/* session-event types via SessionEventMap declaration merging, and the capability-seam RFC. Wires the package into the three root tsconfigs and the cordis catalog. A backend implementation lands separately. --- docs/cordis-catalog/events-and-services.md | 20 +++- docs/module-graph.md | 3 + docs/rfc/README.md | 1 + .../2026-06-18-compaction-capability-seam.md | 57 ++++++++++ packages/compact/compact/README.md | 52 +++++++++ packages/compact/compact/package.json | 32 ++++++ packages/compact/compact/src/index.ts | 102 ++++++++++++++++++ packages/compact/compact/src/types.ts | 57 ++++++++++ .../compact/compact/tests/compact.spec.ts | 78 ++++++++++++++ packages/compact/compact/tsconfig.json | 14 +++ pnpm-lock.yaml | 12 +++ tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.typecheck.json | 1 + 14 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md create mode 100644 packages/compact/compact/README.md create mode 100644 packages/compact/compact/package.json create mode 100644 packages/compact/compact/src/index.ts create mode 100644 packages/compact/compact/src/types.ts create mode 100644 packages/compact/compact/tests/compact.spec.ts create mode 100644 packages/compact/compact/tsconfig.json diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 1ee86d6a5e..400187dafa 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,6 +339,24 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.compact` — `CompactService` (abstract seam) + +Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. + +Implementations MUST honor: + +- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance). +- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. + +```ts cordis-catalog +abstract compactIfNeeded( session: Session, systemPrompt?: string, model?: string, ): Promise +abstract compactRegion( session: Session, start: number, end: number, model: string, ): Promise +``` + +Source: [`packages/compact/compact/src/index.ts:57`](../../packages/compact/compact/src/index.ts) + ### `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..d633fb7787 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,6 +18,8 @@ graph TD agent --> brand agent --> llm agent --> session + compact --> llm + compact --> session llm-replay --> llm llm-replay --> session session-persistence --> session @@ -78,6 +80,7 @@ graph TD | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `compact` | `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index dd34e66816..05029e1ac8 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -44,6 +44,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | +| [Compaction as a capability seam (abstract contract + basic backend)](proposed/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md new file mode 100644 index 0000000000..3725a3db84 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md @@ -0,0 +1,57 @@ +# RFC: Compaction as a capability seam (abstract contract + basic backend) + +Status: proposed (2026-06-18) + +## Context + +A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. + +The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. + +Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. + +## Decision + +### Compaction is a capability seam, split interface / implementation + +Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: + +1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.generate()`, the surface replacement, the lock, and the `agent/request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. + +### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation + +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). + +This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. We record the deviation here so a future reader doesn't mistake it for an accident or "fix" it by smuggling `Session` behind an opaque handle. + +### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend + +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy (e.g. turn-count instead of token-budget) or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. + +### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary + +Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the summary `ContentBlock[]` and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance), never on the surface: + +``` +compact/start → log-only. Acquires the lock. +[summarize older range via the backend] +compact/summary → log-only. Provenance: summary, range, shadowed seqs, token count. +compact/end → log-only. Releases the lock. +user/message → surfaceOp { op:'replace', start, end }. THE surface mutation. + deriveMessages() renders it as a user-role message. +``` + +`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. An alternative — extending `SurfaceEventType` to admit a `compact/*` type — was rejected: the closed union is a deliberate safety boundary (only message-producing events reach the model), and a summary genuinely *is* user-role context, so reusing `user/message` is honest rather than a workaround. + +### Blocking via a log-recorded lock, not a mutex + +Compaction must be serialized: no second compaction starts before the first finishes, and no ordinary events interleave the slow summarization. Rather than an in-memory mutex (invisible to replay, lost on crash), the lock **is** the log: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. `compact/start` is appended first (fast, synchronous), the slow model call runs, then `compact/end` is appended — in a `catch` that records the error, so a failed summarization can never wedge the lock. Because the backend runs compaction synchronously inside the `agent/request` waterfall, the loop is single-threaded for that window; the lock additionally gives observability and lets a persistence backend detect an orphaned `compact/start` on reload. + +## Consequences + +- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the three root tsconfigs. The consumer tier is deferred. +- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. +- **No changes** to `dsh-session`, `dsh-invariants`, or `dsh-agent-loop`: the surface replace op, the surface-metadata runtime guard, and the `agent/request` waterfall all already exist. Compaction is a pure plugin on documented seams. +- The capability-seams convention gains a second reference beyond bash, and a documented case where "interface depends only on cordis" relaxes to "depends only on interface/vocabulary packages the contract genuinely names." On acceptance, [AGENTS.md](../../../../AGENTS.md) § Conventions and [architecture.md](../../../architecture.md) § "Capability seams" should note this relaxation. diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md new file mode 100644 index 0000000000..e98cdd52a6 --- /dev/null +++ b/packages/compact/compact/README.md @@ -0,0 +1,52 @@ +# @deepseek-ai/dsh-compact + +The **compaction seam**: an abstract `CompactService` (`ctx.compact`) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW. + +This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` | +| `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | +| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | + +Unlike the bash seam, this interface 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 "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). + +## Service API (`ctx.compact`) + +Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization). + +| Member | Semantics | +|---|---| +| `compactIfNeeded(session, systemPrompt?, model?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. | +| `compactRegion(session, start, end, model)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. | + +## Surface contract + +`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: + +1. appends `compact/start` (log-only) — acquires the lock, +2. summarizes the range, +3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, +4. appends `compact/end` (log-only) — releases the lock, +5. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**. + +`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 via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock. + +## Events + +The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`: + +| Event | Payload | On surface? | +|---|---|---| +| `compact/start` | `{ turn }` | no (log-only) | +| `compact/summary` | `{ summary, compactedRange, compactedEventSeqs, tokenCount }` | no (log-only) | +| `compact/end` | `{ turn, error? }` | no (log-only) | + +## Implementing a backend + +Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. See `@deepseek-ai/dsh-compact-basic` for the reference implementation. diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json new file mode 100644 index 0000000000..7658bd8f40 --- /dev/null +++ b/packages/compact/compact/package.json @@ -0,0 +1,32 @@ +{ + "name": "@deepseek-ai/dsh-compact", + "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts new file mode 100644 index 0000000000..acd10d4c90 --- /dev/null +++ b/packages/compact/compact/src/index.ts @@ -0,0 +1,102 @@ +/** + * The compaction service seam (`ctx.compact`): an abstract service defining + * WHAT compaction does — decide when to compact, summarize a range of + * conversation history into a single surface node — without saying HOW. + * + * Implementations subclass {@link CompactService}, implement + * {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion}, + * and load as a plugin — registering as `ctx.compact` (one implementation per + * context). `@deepseek-ai/dsh-compact-basic` (char/4 estimation + token-budget + * retention + `ctx.llm.stream()` summarization) is the first. A tokenizer- or + * template-based backend swaps in without touching consumers. + * + * The split follows the capability-seams RFC — interface (this) / + * implementation (`dsh-compact-basic`) / consumer (a `/compact` tool, deferred) + * — modeled on the bash trio. Unlike `dsh-bash`, this interface necessarily + * depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over + * a `Session` and its output is the `ContentBlock` vocabulary. That deviation + * from the "interface depends only on cordis" guidance is intentional and + * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). + * + * @module @deepseek-ai/dsh-compact + */ + +import { Context, Service } from 'cordis' +import type { Session } from '@deepseek-ai/dsh-session' +import type { CompactionResult } from './types.ts' + +export type { CompactionResult } from './types.ts' + +declare module 'cordis' { + interface Context { + compact: CompactService + } +} + +/** + * Abstract compaction service. Subclass implement the two abstract methods, + * and load the subclass as a plugin — it registers as `ctx.compact` (one + * implementation per context; loading a second throws, which is cordis' + * standard duplicate-service behavior). + * + * Both core methods are abstract: the contract states WHAT compaction does, + * while the entire strategy — token estimation, retention policy, event + * sequencing, summarization — is a HOW decision owned by the implementation. + * + * Implementations MUST honor: + * - **Surface contract**: a successful compaction shadows the compacted surface + * nodes with a SINGLE replacement node carrying the summary. Because + * `SurfaceEventType` is a closed union, that node is a `user/message` with + * `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are + * log-only (lock + provenance). + * - **Blocking**: no compaction begins while another is in progress for the + * same session. The recommended mechanism is the log-recorded lock — append + * `compact/start` before the slow work and `compact/end` after (even on + * failure) — so the lock is visible to replay and crash recovery. + */ +export abstract class CompactService extends Service { + constructor(ctx: Context) { + super(ctx, 'compact') + } + + /** + * Check token pressure and compact if the conversation is too large. + * + * Estimates the current history size (optionally including a system prompt), + * and if it exceeds the backend's threshold, compacts an older range via + * {@link compactRegion}, keeping recent context intact. + * + * @param session - the session whose surface may be compacted. + * @param systemPrompt - optional system prompt, counted toward the estimate. + * @param model - optional summarization model (falls back to backend config). + * @returns the compaction result, or `null` if no compaction was needed. + */ + abstract compactIfNeeded( + session: Session, + systemPrompt?: string, + model?: string, + ): Promise + + /** + * Forcibly compact a range of surface nodes into a single summary node. + * + * `start` and `end` are inclusive seqs of surface nodes to shadow; the backend + * summarizes their content and appends a replacement surface node. Used by the + * (future) `/compact` tool and internally by {@link compactIfNeeded}. + * + * @param session - the session whose surface is mutated. + * @param start - inclusive seq of the first surface node to compact. + * @param end - inclusive seq of the last surface node to compact. + * @param model - summarization model. + * @throws if compaction is already in progress, or if `start`/`end` are not + * valid surface nodes, or if `start > end`. + */ + abstract compactRegion( + session: Session, + start: number, + end: number, + model: string, + ): Promise +} + +export default CompactService diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts new file mode 100644 index 0000000000..08b37ef4c6 --- /dev/null +++ b/packages/compact/compact/src/types.ts @@ -0,0 +1,57 @@ +/** + * Compaction vocabulary: the result type and the `compact/*` session events. + * + * Extends {@link SessionEventMap} with `compact/*` event types via declaration + * merging. {@link SurfaceEventType} is deliberately NOT extended — `compact/*` + * events are log-only markers (lock + provenance); only the five + * surface-eligible types can carry `surfaceOp`. The actual surface mutation is + * performed by a separate `user/message` event carrying the summary (see the + * [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). + * + * Configuration lives in the backend, not here: the contract states WHAT + * compaction produces, while every tunable (context window, thresholds, + * retention budget) is a HOW decision owned by the implementation. + * + * @module @deepseek-ai/dsh-compact/types + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ + 'compact/start': { turn: number } + /** + * Provenance record of a completed summarization — log-only, no surfaceOp. + * The summary content is in `data.summary`; the actual surface replacement + * is performed by a subsequent `user/message` event that shadows the + * compacted range. + */ + 'compact/summary': { + summary: ContentBlock[] + compactedRange: { startSeq: number; endSeq: number } + compactedEventSeqs: number[] + tokenCount: number + } + /** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ + 'compact/end': { turn: number; error?: string } + } +} + +/** Result of a successful compaction operation. */ +export interface CompactionResult { + /** The seq of the appended `compact/start` event. */ + startSeq: number + /** The seq of the appended `compact/summary` event. */ + summarySeq: number + /** The seq of the appended `compact/end` event. */ + endSeq: number + /** The summary content blocks produced by the backend. */ + summary: ContentBlock[] + /** The seq range that was shadowed [start, end] inclusive. */ + shadowedRange: { start: number; end: number } + /** The seq numbers of all shadowed surface nodes. */ + shadowedSeqs: number[] + /** Estimated token count of the shadowed content. */ + compactedTokenCount: number +} diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts new file mode 100644 index 0000000000..c4f0c0f838 --- /dev/null +++ b/packages/compact/compact/tests/compact.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CompactService } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +/** + * A trivial concrete CompactService implementing the abstract contract. The + * interface package owns no algorithm — these tests exercise the seam itself: + * service registration, the abstract method shape, and the `compact/*` event + * declaration merge. + */ +class StubCompactService extends CompactService { + override async compactIfNeeded(_session: Session, _systemPrompt?: string, _model?: string): Promise { + return null + } + + override async compactRegion(session: Session, start: number, end: number, _model: string): Promise { + // Minimal stub honoring the lock + log-only event contract. + const startEvent = session.append('compact/start', { turn: 0 }) + const summaryEvent = session.append('compact/summary', { + summary: [{ type: 'text', text: 'stub' }], + compactedRange: { startSeq: start, endSeq: end }, + compactedEventSeqs: [], + tokenCount: 0, + }) + const endEvent = session.append('compact/end', { turn: 0 }) + return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary: [{ type: 'text', text: 'stub' }], + shadowedRange: { start, end }, + shadowedSeqs: [], + compactedTokenCount: 0, + } + } +} + +describe('CompactService seam', () => { + it('registers as ctx.compact', () => { + const ctx = new Context() + void new StubCompactService(ctx) + expect(ctx.compact).toBeDefined() + expect(ctx.compact).toBeInstanceOf(StubCompactService) + }) + + it('disposing the fiber unregisters ctx.compact (HMR safety)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(StubCompactService) + expect(ctx.compact).toBeInstanceOf(StubCompactService) + await fiber.dispose() + expect(ctx.compact).toBeUndefined() + }) + + it('exposes the abstract contract methods', async () => { + const ctx = new Context() + const svc = new StubCompactService(ctx) + expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull() + }) + + it('compact/* events merge into SessionEventMap and are log-only', async () => { + const ctx = new Context() + const svc = new StubCompactService(ctx) + const session = new Session(SessionId('s')) + + const result = await svc.compactRegion(session, 0, 0, 'm') + + const startEvent = session.events.find(e => e.type === 'compact/start') + expect(startEvent).toBeDefined() + // Log-only: the compiler rejects surfaceOp on compact/* (not a SurfaceEventType); + // verify the runtime value is absent. + const raw = startEvent as unknown as { surfaceOp?: unknown } + expect(raw.surfaceOp).toBeUndefined() + expect(result.summarySeq).toBeGreaterThan(result.startSeq) + expect(result.endSeq).toBeGreaterThan(result.summarySeq) + }) +}) diff --git a/packages/compact/compact/tsconfig.json b/packages/compact/compact/tsconfig.json new file mode 100644 index 0000000000..a16d13abac --- /dev/null +++ b/packages/compact/compact/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..4c4f33e613 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,18 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/compact/compact: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..9fa9c52369 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/compact/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..d73da9ffc0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,7 @@ { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, + { "path": "./packages/compact/compact" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..93008005d8 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -20,6 +20,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/compact/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src",