From 66b2cfc6098f4fe1ee795707ccf0ab87a781fea3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 15 Jul 2026 17:34:24 +0800 Subject: [PATCH 01/48] docs(rfc): propose LSP capability seam --- docs/rfc/INDEX.md | 1 + .../2026-07-15-lsp-capability-seam.i18n.yaml | 6 + .../2026-07-15-lsp-capability-seam.md | 198 ++++++++++++++++++ .../2026-07-15-lsp-capability-seam.zh.md | 198 ++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a374e795bc..a0dd29d0b5 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,6 +28,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml new file mode 100644 index 0000000000..75d69a5dcb --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -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 +2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5 +2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3 diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md new file mode 100644 index 0000000000..89e58c3ae0 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md @@ -0,0 +1,198 @@ +# RFC: LSP capability seam and model-facing query tool + +Status: proposed + +English | [中文](2026-07-15-lsp-capability-seam.zh.md) + +## Problem + +The harness has text search and file reads, but neither identifies a program symbol. A textual match cannot reliably distinguish two same-named functions, follow an import alias, connect an interface to its implementations, or report an inferred type. Before changing code, an agent therefore lacks the semantic navigation that a human gets from an editor's language server. + +LSP support has three owners: the model needs a stable query schema, the harness needs provider selection and normalized results, and the local implementation needs process, JSON-RPC, workspace, synchronization, and filesystem behavior. Combining them would bind the model contract to local subprocesses and obstruct remote or sandbox-native providers. + +Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index. + +## Proposal + +Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: + +1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings. +3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. + +`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. + +The model and seam expose exactly `definition`, `references`, `implementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. + +The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## Package and ownership boundaries + +`dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities. + +The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` validates, selects, and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. + +The intended contract shape is: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch. + +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. + +## Model-facing contract + +The single `lsp` tool accepts: + +```ts +interface LspToolInput { + readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input. + +The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace. + +Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100`, and `maxHoverChars` defaults to `16_000` after hover normalization; both report omissions. Empty locations and `null` hover are successful no-result responses; malformed payloads remain structured errors. + +ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure. + +## Timeout ownership + +`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable. + +The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. + +Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) before hard kill; the same bounds govern failed-instance cleanup. It uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. + +## Workspace, filesystem, and document synchronization + +`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one handle through validation and reading. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. + +The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers. + +The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. + +1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. +2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. +3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. +4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. + +Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-instance queue serializes complete lifecycles; distinct instances may run in parallel. The server's workspace index remains responsible for closed files reached from the source. + +The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider. + +## Local server lifecycle and protocol behavior + +`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; resolution stays lazy and launch uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. + +Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Hover normalization takes `MarkupContent.value`, preserves string `MarkedString` values, renders language-tagged values as fenced code, joins arrays with one blank line, and applies `maxHoverChars` last. + +Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. + +## Deliberately deferred surface + +Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation. + +Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration. + +The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider. + +## Alternatives considered + +**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries. + +**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers. + +**Expose arbitrary LSP methods.** A JSON-RPC escape hatch would leak protocol payloads and admit unreviewed mutation or command execution; the operation union stays closed. + +**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime. + +**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it. + +**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess. + +**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine. + +**Configure phase timeouts.** Nested timers create competing classifications and fresh budgets. One caller-owned deadline covers query work; only out-of-call teardown keeps local bounds. + +**Query without `didOpen`.** Although permitted, support is inconsistent and may use stale server state. Transient open supplies an explicit current snapshot. + +**Add routes or select the first match.** Registration order and HMR timing are not product semantics, while a route table duplicates unique extension ownership. Overlaps therefore fail registration. + +**Run concurrent queries in one instance.** If cancellation fails, terminating the shared process would kill unrelated work. Per-instance serialization limits that blast radius; instances remain parallel. + +**Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets. + +## Acceptance criteria + +- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. +- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. +- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. +- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `references.includeDeclaration`. +- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. +- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. +- Lifecycle tests pin startup single-flight, per-instance serialization, cross-instance parallelism, abortable queues, crash replacement without replay, and quiescent disposal. +- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. +- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping. +- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. +- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change. + +## Risks + +Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. + +Transient opens repeat parsing and notifications. Per-instance serialization increases latency under parallel agents, and long-lived workspace processes consume memory until disposal. + +Extension ownership is exclusive within one runtime. Two providers cannot both claim `.ts`, even with different language ids; this is a conscious MVP limit. The intended extension is a deployment-configured selector above registrations that can relax exclusive reservations without adding provider choice to model input or changing `LspProvider.query`. + +UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use. + +Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee. diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md new file mode 100644 index 0000000000..c1c2448b1e --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -0,0 +1,198 @@ +# RFC: LSP 能力服务边界与面向模型的查询工具 + +Status: proposed + +[English](2026-07-15-lsp-capability-seam.md) | 中文 + +## 问题 + +harness 已具备文本搜索与文件读取能力,但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现,也无法报告推断类型。因此,agent(智能体)在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。 + +语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。 + +许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 + +## 提案 + +将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: + +1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 +3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 + +`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 + +模型与服务边界仅公开 `definition`、`references`、`implementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。 + +提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## Package 与职责边界 + +`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。 + +服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行校验、选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 + +预期契约如下: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 + +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 + +## 面向模型的契约 + +单一 `lsp` 工具接受以下参数: + +```ts +interface LspToolInput { + readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`references` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 + +工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 + +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,`maxHoverChars` 在 `hover` 归一化后应用,默认值为 `16_000`;两者都会报告省略数量。空位置与 `null` hover 是成功的无结果响应;格式错误的载荷保持为结构化错误。 + +ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 + +## 超时归属 + +`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。 + +服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 + +提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`)限制强制终止前的宽限期;失败实例的清理也使用相同边界。它使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 + +## 工作区、文件系统与文档同步 + +`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 + +`read` 工具的输出带窗口与行号,进入 transcript 且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 + +本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 + +1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 +2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。 +3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 +4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 + +每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个实例使用一个可取消队列串行执行完整生命周期;不同实例可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 + +规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。 + +## 本地服务器生命周期与协议行为 + +`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败,解析保持懒执行,启动不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 + +初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 + +导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。`hover` 归一化直接采用 `MarkupContent.value`,保留字符串 `MarkedString`,把带语言标签的值渲染为围栏代码块,以一个空行连接数组,并在最后应用 `maxHoverChars`。 + +取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。 + +## 明确延后的接口 + +符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。 + +诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。 + +本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。 + +## 备选方案 + +**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。 + +**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。 + +**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。 + +**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。 + +**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。 + +**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。 + +**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。 + +**配置分阶段超时。** 嵌套定时器会产生相互竞争的分类与新预算。一个由调用方负责的截止时间覆盖查询;只有调用外清理保留本地限制。 + +**不发送 `didOpen`。** 协议虽允许,但支持不一致且可能使用陈旧服务器状态。临时打开提供明确的当前快照。 + +**增加路由或选择首个匹配项。** 注册顺序与 HMR 时机不是产品语义,路由表又会重复唯一扩展名所有权。因此,扩展名重叠时注册失败。 + +**在一个实例中并发查询。** 取消失败时,终止共享进程会杀死无关工作。实例内串行可限制影响范围;不同实例仍可并行。 + +**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。 + +## 验收标准 + +- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 +- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 +- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 +- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 +- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 +- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 +- 生命周期测试固定启动 single-flight、实例内串行、跨实例并行、可取消队列、崩溃后不重放的替换,以及释放后完全停稳。 +- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 +- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 +- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 +- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 + +## 风险 + +各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 + +临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。 + +同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector,允许放宽互斥占用,同时不向模型输入增加提供方选择,也不改变 `LspProvider.query`。 + +UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。 + +直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。 From d0029d8d609d297ede039cc579fed8cc89d1705c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 12:05:35 +0800 Subject: [PATCH 02/48] feat(lsp): LSP capability seam, generic stdio provider, and lsp tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the LSP capability seam RFC as three packages: dsh-lsp (the ctx.lsp interface — provider registry by branded id + exclusive extension mapping, per-query order-independent selection, closed request/result vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server provider — Content-Length JSON-RPC framing, per-(provider, workspace) process single-flight, transient didOpen/query/didClose, an abortable per-instance queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool — four operations, one-based UTF-16 cursor conversion, workspace-grouped location rendering, hover capping, a required session workspace, and a timeout budget). Why: an agent had text search and file reads but no way to identify a program symbol — follow an alias, connect an interface to implementations, or read an inferred type — before changing code. Splitting model contract, seam, and local subprocess behavior keeps the four semantic queries stable across future remote or sandbox-native providers without leaking a JSON-RPC escape hatch. --- AGENTS.md | 15 +- docs/architecture.md | 1 + docs/config-catalog.md | 55 ++++ docs/module-graph.md | 18 ++ docs/rfc/INDEX.md | 2 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 8 +- .../2026-07-15-lsp-capability-seam.zh.md | 8 +- docs/tool-catalog.md | 47 +++ knip.json | 5 + packages/README.md | 16 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/lsp/README.md | 13 + packages/lsp/lsp-local/README.md | 49 +++ packages/lsp/lsp-local/package.json | 43 +++ packages/lsp/lsp-local/src/connection.ts | 240 ++++++++++++++ packages/lsp/lsp-local/src/framing.ts | 99 ++++++ packages/lsp/lsp-local/src/host.ts | 104 +++++++ packages/lsp/lsp-local/src/index.ts | 255 +++++++++++++++ packages/lsp/lsp-local/src/instance.ts | 293 ++++++++++++++++++ packages/lsp/lsp-local/src/protocol.ts | 80 +++++ packages/lsp/lsp-local/src/translate.ts | 210 +++++++++++++ packages/lsp/lsp-local/tests/built-lib.e2e.ts | 73 +++++ .../lsp/lsp-local/tests/connection.spec.ts | 226 ++++++++++++++ .../lsp/lsp-local/tests/fixture-server.ts | 144 +++++++++ packages/lsp/lsp-local/tests/framing.spec.ts | 76 +++++ packages/lsp/lsp-local/tests/host.spec.ts | 105 +++++++ packages/lsp/lsp-local/tests/instance.spec.ts | 184 +++++++++++ .../lsp/lsp-local/tests/lifecycle.spec.ts | 200 ++++++++++++ packages/lsp/lsp-local/tests/provider.spec.ts | 78 +++++ .../lsp/lsp-local/tests/translate.spec.ts | 153 +++++++++ .../lsp-local/tests/typescript-server.e2e.ts | 111 +++++++ packages/lsp/lsp-local/tsconfig.json | 33 ++ packages/lsp/lsp/README.md | 38 +++ packages/lsp/lsp/package.json | 34 ++ packages/lsp/lsp/src/brand.ts | 21 ++ packages/lsp/lsp/src/index.ts | 156 ++++++++++ packages/lsp/lsp/src/types.ts | 124 ++++++++ packages/lsp/lsp/tests/lsp.spec.ts | 187 +++++++++++ packages/lsp/lsp/tsconfig.json | 24 ++ packages/lsp/tool-lsp/README.md | 56 ++++ packages/lsp/tool-lsp/package.json | 45 +++ packages/lsp/tool-lsp/src/index.ts | 130 ++++++++ packages/lsp/tool-lsp/src/render.ts | 158 ++++++++++ packages/lsp/tool-lsp/src/session-cwd.ts | 19 ++ .../lsp/tool-lsp/tests/integration.spec.ts | 93 ++++++ packages/lsp/tool-lsp/tests/load-path.spec.ts | 24 ++ packages/lsp/tool-lsp/tests/render.spec.ts | 125 ++++++++ packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 164 ++++++++++ packages/lsp/tool-lsp/tsconfig.json | 33 ++ pnpm-lock.yaml | 147 ++++++++- scripts/gen-tool-catalog.ts | 16 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 1 + tsconfig.build.json | 5 +- tsconfig.json | 5 +- 56 files changed, 4527 insertions(+), 30 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.i18n.yaml (65%) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.md (99%) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.zh.md (99%) create mode 100644 packages/lsp/README.md create mode 100644 packages/lsp/lsp-local/README.md create mode 100644 packages/lsp/lsp-local/package.json create mode 100644 packages/lsp/lsp-local/src/connection.ts create mode 100644 packages/lsp/lsp-local/src/framing.ts create mode 100644 packages/lsp/lsp-local/src/host.ts create mode 100644 packages/lsp/lsp-local/src/index.ts create mode 100644 packages/lsp/lsp-local/src/instance.ts create mode 100644 packages/lsp/lsp-local/src/protocol.ts create mode 100644 packages/lsp/lsp-local/src/translate.ts create mode 100644 packages/lsp/lsp-local/tests/built-lib.e2e.ts create mode 100644 packages/lsp/lsp-local/tests/connection.spec.ts create mode 100644 packages/lsp/lsp-local/tests/fixture-server.ts create mode 100644 packages/lsp/lsp-local/tests/framing.spec.ts create mode 100644 packages/lsp/lsp-local/tests/host.spec.ts create mode 100644 packages/lsp/lsp-local/tests/instance.spec.ts create mode 100644 packages/lsp/lsp-local/tests/lifecycle.spec.ts create mode 100644 packages/lsp/lsp-local/tests/provider.spec.ts create mode 100644 packages/lsp/lsp-local/tests/translate.spec.ts create mode 100644 packages/lsp/lsp-local/tests/typescript-server.e2e.ts create mode 100644 packages/lsp/lsp-local/tsconfig.json create mode 100644 packages/lsp/lsp/README.md create mode 100644 packages/lsp/lsp/package.json create mode 100644 packages/lsp/lsp/src/brand.ts create mode 100644 packages/lsp/lsp/src/index.ts create mode 100644 packages/lsp/lsp/src/types.ts create mode 100644 packages/lsp/lsp/tests/lsp.spec.ts create mode 100644 packages/lsp/lsp/tsconfig.json create mode 100644 packages/lsp/tool-lsp/README.md create mode 100644 packages/lsp/tool-lsp/package.json create mode 100644 packages/lsp/tool-lsp/src/index.ts create mode 100644 packages/lsp/tool-lsp/src/render.ts create mode 100644 packages/lsp/tool-lsp/src/session-cwd.ts create mode 100644 packages/lsp/tool-lsp/tests/integration.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/load-path.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/render.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/tool-lsp.spec.ts create mode 100644 packages/lsp/tool-lsp/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 22c1f47aa8..edc5f6a74a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools + lsp/ LSP seam + stdio provider + lsp tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend @@ -90,13 +91,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). -- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. -- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. +- **Model-visible ⟺ logged**: anything reaching a model request must be reconstructable from the session log; a new model-visible input requires a session event. +- **Plugins, not loop changes**: new behavior goes on documented extension seams; changing `agent-loop` requires updating docs/architecture.md. - **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively. - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template). - **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. @@ -119,15 +120,15 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, ## Type safety and documentation -Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class. +Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep docs at the declaring seam, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally. +Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, with narrow justified exceptions rather than disabling a rule. -Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). +Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document current state not history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. +`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling only when the contract needs more space. ## Vendoring policy diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..db3cc19f22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | +| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | language-server provider registry and semantic navigation | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e3576d05..a38130a739 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -419,6 +419,42 @@ export interface Config { Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-lsp-local` + +Requires: `lsp` + +```ts config-catalog +/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +export interface Config { + /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ + providerId: string + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Arguments passed to the executable (no shell). */ + args: string[] + /** Extra env vars merged on top of the scrubbed ambient env. */ + env: Record + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Static `initialize` options forwarded to the server. */ + initializationOptions: unknown + /** Static answer to every `workspace/configuration` item. */ + configuration: unknown + /** Largest single framed message accepted from the server (bytes). */ + maxMessageBytes: number + /** Largest stderr tail retained for diagnostics (bytes). */ + maxStderrBytes: number + /** Largest source file this host will open (bytes). */ + maxDocumentBytes: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). */ + shutdownTimeoutMs: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ + killGraceMs: number +} +``` + +Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts) + ## `@deepseek-ai/dsh-mcp-client` Requires: `tools` @@ -901,6 +937,24 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-tool-lsp` + +Requires: `tools` · `lsp` · `systemPrompt` + +```ts config-catalog +/** Plugin configuration: result caps and the timeout budget. */ +export interface Config { + /** Largest number of rendered locations before an omission marker (default 100). */ + maxLocations?: number + /** Largest hover length in characters after normalization (default 16000). */ + maxHoverChars?: number + /** Tool-call timeout budget in ms (default 60000). */ + timeoutMs?: number +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1214,6 +1268,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) +- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1b804e5214..f1bc52eabc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -115,6 +115,11 @@ flowchart TD subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end + subgraph group_lsp["packages/lsp"] + pkg_lsp["lsp"] + pkg_lsp_local["lsp-local"] + pkg_tool_lsp["tool-lsp"] + end subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end @@ -139,6 +144,8 @@ flowchart TD pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_llm pkg_agent --> pkg_brand pkg_agent --> pkg_llm @@ -162,6 +169,10 @@ flowchart TD pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_bash_local --> pkg_bash @@ -273,6 +284,10 @@ flowchart TD pkg_tool_ask_user --> pkg_user_interaction pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools + pkg_tool_lsp --> pkg_llm + pkg_tool_lsp --> pkg_lsp + pkg_tool_lsp --> pkg_system_prompt + pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools pkg_tool_workflow --> pkg_agent @@ -370,6 +385,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -383,6 +399,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -412,6 +429,7 @@ flowchart TD | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a0dd29d0b5..4587338244 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,7 +28,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process @@ -146,6 +145,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | +| [LSP capability seam and model-facing query tool](implemented/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml similarity index 65% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 75d69a5dcb..f8b32dcca1 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5 -2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3 +2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983 +2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md similarity index 99% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 89e58c3ae0..90cc7fce8c 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -1,6 +1,6 @@ # RFC: LSP capability seam and model-facing query tool -Status: proposed +Status: implemented English | [中文](2026-07-15-lsp-capability-seam.zh.md) @@ -12,7 +12,7 @@ LSP support has three owners: the model needs a stable query schema, the harness Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index. -## Proposal +## Decision Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: @@ -171,7 +171,7 @@ The local provider trusts its configured server and claims no sandbox confinemen **Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets. -## Acceptance criteria +## Testing - Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. @@ -185,7 +185,7 @@ The local provider trusts its configured server and claims no sandbox confinemen - Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. - Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change. -## Risks +## Consequences Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md similarity index 99% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index c1c2448b1e..12873d3368 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -1,6 +1,6 @@ # RFC: LSP 能力服务边界与面向模型的查询工具 -Status: proposed +Status: implemented [English](2026-07-15-lsp-capability-seam.md) | 中文 @@ -12,7 +12,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 -## 提案 +## 决策 将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: @@ -171,7 +171,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p **内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。 -## 验收标准 +## 测试 - Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 @@ -185,7 +185,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 - Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 -## 风险 +## 影响 各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9ea71d3005..91be6752d6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | @@ -371,6 +372,52 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +## `@deepseek-ai/dsh-tool-lsp` + +### `lsp` + +Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration. + +```json +{ + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "definition, references, implementation, or hover.", + "enum": [ + "definition", + "references", + "implementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts`](../packages/lsp/tool-lsp/src/index.ts) + +The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/knip.json b/knip.json index ad33bec613..76634b0b78 100644 --- a/knip.json +++ b/knip.json @@ -118,6 +118,11 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"] + }, + "packages/lsp/lsp-local": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["typescript-language-server"] } } } diff --git a/packages/README.md b/packages/README.md index 5cb4cfe419..fafa269514 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,10 +1,10 @@ # Packages -Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). ## Hierarchy -Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code. +Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-` regardless of group. **Each group README is the canonical per-package map** — roles, ctx keys, and the product-vs-support split live there, next to the code. | Group | Role | Release expectation | |---|---|---| @@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | +| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface | @@ -23,20 +24,19 @@ Packages are grouped by modular role at `packages///`. The group dir | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | -| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | +| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect live plugins/services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration: ACP bridge, JSON-RPC SDK server, app packages, user-approval/interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | -The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table). +The split is the point: a package's group says whether it is product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a top-level group is a deliberate act (extend the group READMEs and this table). ## Dependencies The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). +The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other spine plugins). The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). -The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). - -Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). +Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ad739173c9..448a14d5d0 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'lsp', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/lsp/README.md b/packages/lsp/README.md new file mode 100644 index 0000000000..57a1f6b8d9 --- /dev/null +++ b/packages/lsp/README.md @@ -0,0 +1,13 @@ +# lsp/ - LSP capability family + +The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | +| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) | +| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | + +The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. + +See the [LSP capability seam RFC](../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md new file mode 100644 index 0000000000..f9e4b32713 --- /dev/null +++ b/packages/lsp/lsp-local/README.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-lsp-local + +A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays. + +Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). + +## What it does + +- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. +- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. +- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. | +| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | +| `args` | `[]` | Arguments passed to the executable. | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | +| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | +| `configuration` | `null` | Static answer to every `workspace/configuration` item. | +| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. | +| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | +| `maxDocumentBytes` | `4000000` | Largest source file this host will open. | +| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | +| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | + +The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query. + +## Protocol behavior + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. + +## Security boundary + +The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself. + +## Known Limitations and Deferred Work + +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. +- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json new file mode 100644 index 0000000000..d437e91109 --- /dev/null +++ b/packages/lsp/lsp-local/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-lsp-local", + "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7", + "typescript": "^6.0.3", + "typescript-language-server": "^5.0.0" + } +} diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts new file mode 100644 index 0000000000..1eeb430d22 --- /dev/null +++ b/packages/lsp/lsp-local/src/connection.ts @@ -0,0 +1,240 @@ +/** + * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound + * requests/notifications, and inbound server→client requests: it answers `workspace/configuration` + * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs + * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the + * child handle so the instance owns process-signal teardown. + * @module @deepseek-ai/dsh-lsp-local/connection + */ + +import type { ChildProcessByStdio } from 'node:child_process' +import { spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' +import { encodeMessage, MessageDecoder } from './framing.ts' + +/** How to launch the server and answer its config requests. */ +export interface ConnectionSpec { + /** The resolved absolute executable path (no shell). */ + readonly command: string + /** Arguments passed to the executable. */ + readonly args: readonly string[] + /** The child's working directory (the canonical workspace). */ + readonly cwd: string + /** The child's environment (credential-scrubbed, with overrides applied). */ + readonly env: Record + /** Largest single framed message accepted from the server. */ + readonly maxMessageBytes: number + /** Largest stderr tail retained for diagnostics. */ + readonly maxStderrBytes: number + /** Static answer to every `workspace/configuration` item. */ + readonly configuration: unknown +} + +interface Pending { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +/** A live JSON-RPC endpoint bound to one child process. */ +export class LspConnection { + private readonly child: ChildProcessByStdio + private readonly decoder: MessageDecoder + private readonly pending = new Map() + private nextId = 1 + private stderr = '' + private closeReason: Error | undefined + /** Set once the process has fully exited; the instance awaits it during teardown. */ + readonly closed: Promise + + /** + * @param spec - how to launch the server and answer its config requests. + * @param onServerRequest - answers a server→client request; rejects to send an error response. + */ + constructor( + private readonly spec: ConnectionSpec, + private readonly onServerRequest: (method: string, params: unknown) => Promise, + ) { + this.decoder = new MessageDecoder(spec.maxMessageBytes) + this.child = spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: spec.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.closed = new Promise((resolve) => { + this.child.on('close', () => { + const reason = this.closeReason ?? new Error('language server exited') + // Record the reason so any request issued AFTER close rejects immediately instead of hanging + // (a closed process sends no further responses). + this.closeReason = reason + this.failAll(reason) + resolve() + }) + }) + this.child.on('error', (error) => { this.fail(error) }) + // A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE + // during teardown does not crash the process. Pending requests fail via the 'close' handler. + /* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */ + this.child.stdin.on('error', () => { /* swallow */ }) + this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) + this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + } + + /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ + get pid(): number { + /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ + return this.child.pid ?? -1 + } + + /** The retained stderr tail, for diagnostics on a failed server. */ + get stderrTail(): string { + return this.stderr + } + + /** + * Send a request and await its result. + * @param method - the JSON-RPC method. + * @param params - the request params. + * @returns the response result; rejects on an error response, write failure, or close. + */ + request(method: string, params: unknown): Promise { + const id = this.nextId++ + const promise = new Promise((resolve, reject) => { + if (this.closeReason !== undefined) { + reject(this.closeReason) + return + } + this.pending.set(id, { resolve, reject }) + try { + this.write({ jsonrpc: '2.0', id, method, params }) + } catch (error) { + /* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed + 'error' listener, so this synchronous catch is a defensive guard. */ + this.pending.delete(id) + reject(asError(error)) + /* v8 ignore stop */ + } + }) + // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later + // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled + // rejection. The returned promise still delivers the rejection to the caller's own await/catch. + promise.catch(() => {}) + return promise + } + + /** + * Send a notification (no id, no response). + * @param method - the JSON-RPC method. + * @param params - the notification params. + */ + notify(method: string, params: unknown): void { + this.write({ jsonrpc: '2.0', method, params }) + } + + /** + * Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure). + * @param requestId - the numeric id of the request to cancel. + */ + cancel(requestId: number): void { + try { + this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }) + } catch { + // The server is already gone or unwritable; the pending request will fail on close. + } + } + + /** + * The id the NEXT `request()` will use, so the instance can pre-arm a cancel. + * @returns the numeric id the next request will be assigned. + */ + peekNextId(): number { + return this.nextId + } + + /** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */ + terminate(): void { + this.child.kill('SIGTERM') + } + + /** Send SIGKILL to the child. */ + kill(): void { + this.child.kill('SIGKILL') + } + + private onStdout(chunk: Buffer): void { + let messages: unknown[] + try { + messages = this.decoder.push(chunk) + } catch (error) { + // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance. + this.fail(asError(error)) + this.child.kill('SIGKILL') + return + } + for (const message of messages) this.dispatch(message) + } + + private onStderr(chunk: Buffer): void { + if (this.stderr.length >= this.spec.maxStderrBytes) return + this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes) + } + + private dispatch(message: unknown): void { + if (message === null || typeof message !== 'object') return + const frame = message as Record + const id = frame.id + const method = frame.method + if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) { + void this.handleServerRequest(id, method, frame.params) + return + } + if (typeof method === 'string') { + // A server→client notification (e.g. diagnostics, logs): ignored by this MVP host. + return + } + if (typeof id === 'number') this.handleResponse(id, frame) + } + + private async handleServerRequest(id: number | string, method: string, params: unknown): Promise { + try { + const result = await this.onServerRequest(method, params) + this.write({ jsonrpc: '2.0', id, result }) + } catch (error) { + this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) + } + } + + private handleResponse(id: number, frame: Record): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + const error = frame.error + if (error !== null && typeof error === 'object') { + const record = error as Record + pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response')) + return + } + pending.resolve(frame.result) + } + + private write(message: unknown): void { + this.child.stdin.write(encodeMessage(message)) + } + + private fail(error: Error): void { + /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ + if (this.closeReason === undefined) this.closeReason = error + this.failAll(error) + } + + private failAll(error: Error): void { + const waiting = [...this.pending.values()] + this.pending.clear() + for (const pending of waiting) pending.reject(error) + } +} + +/** Coerce an unknown thrown value to an `Error`. */ +function asError(value: unknown): Error { + /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */ + return value instanceof Error ? value : new Error(String(value)) +} diff --git a/packages/lsp/lsp-local/src/framing.ts b/packages/lsp/lsp-local/src/framing.ts new file mode 100644 index 0000000000..8720247272 --- /dev/null +++ b/packages/lsp/lsp-local/src/framing.ts @@ -0,0 +1,99 @@ +/** + * LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder + * produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies, + * bounding the header and total message size so a hostile or broken server cannot exhaust memory. + * @module @deepseek-ai/dsh-lsp-local/framing + */ + +/** The header/body separator in the LSP base protocol. */ +const HEADER_SEPARATOR = '\r\n\r\n' + +/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */ +const MAX_HEADER_BYTES = 1 << 16 + +/** + * Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n`). + * @param message - the JSON-RPC message object to serialize. + * @returns the framed bytes ready to write to the server's stdin. + */ +export function encodeMessage(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), 'utf8') + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii') + return Buffer.concat([header, body]) +} + +/** + * A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any + * whole message bodies that completed. It parses only the `Content-Length` header and ignores other + * headers (e.g. `Content-Type`), matching the base protocol. + */ +export class MessageDecoder { + private buffer: Buffer = Buffer.alloc(0) + private readonly maxMessageBytes: number + + /** + * @param maxMessageBytes - reject any single framed body larger than this (guards memory). + */ + constructor(maxMessageBytes: number) { + this.maxMessageBytes = maxMessageBytes + } + + /** + * Append a chunk and return every message body that is now complete. + * @param chunk - raw bytes from the server's stdout. + * @returns the parsed JSON bodies, in arrival order (possibly empty). + * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`. + */ + push(chunk: Buffer): unknown[] { + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const messages: unknown[] = [] + for (;;) { + const step = this.next() + if (!step.ready) break + messages.push(step.message) + } + return messages + } + + /** Parse and consume the next complete message, or report that more bytes are needed. */ + private next(): { ready: false } | { ready: true; message: unknown } { + const separator = this.buffer.indexOf(HEADER_SEPARATOR) + if (separator < 0) { + if (this.buffer.length > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) + } + return { ready: false } + } + const headerText = this.buffer.toString('ascii', 0, separator) + const contentLength = parseContentLength(headerText) + if (contentLength > this.maxMessageBytes) { + throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`) + } + const bodyStart = separator + HEADER_SEPARATOR.length + const bodyEnd = bodyStart + contentLength + if (this.buffer.length < bodyEnd) return { ready: false } + const body = this.buffer.toString('utf8', bodyStart, bodyEnd) + this.buffer = this.buffer.subarray(bodyEnd) + try { + return { ready: true, message: JSON.parse(body) } + } catch (error) { + /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */ + throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } +} + +/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */ +function parseContentLength(headerText: string): number { + for (const line of headerText.split('\r\n')) { + const colon = line.indexOf(':') + if (colon < 0) continue + if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue + const value = Number(line.slice(colon + 1).trim()) + if (!Number.isInteger(value) || value < 0) { + throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`) + } + return value + } + throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) +} diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts new file mode 100644 index 0000000000..a90a703d96 --- /dev/null +++ b/packages/lsp/lsp-local/src/host.ts @@ -0,0 +1,104 @@ +/** + * Host-filesystem source access for the local provider, using Node APIs directly in the + * subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not + * satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target + * identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server + * startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the + * workspace. External result locations are allowed, but an external path can never become a query + * source. + * @module @deepseek-ai/dsh-lsp-local/host + */ + +import { readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, resolve as resolvePath, sep } from 'node:path' + +/** A validated source: its canonical absolute path and current UTF-8 text. */ +export interface HostSource { + /** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */ + readonly canonicalPath: string + /** The file's current text, read as UTF-8. */ + readonly text: string +} + +/** + * Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies + * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots + * collapse to one instance. + * @param workspaceRoot - the caller's workspace root (absolute). + * @returns the canonical directory path. + * @throws Error when the path is missing or not a directory. + */ +export async function canonicalizeWorkspace(workspaceRoot: string): Promise { + let canonical: string + try { + canonical = await realpath(workspaceRoot) + } catch (error) { + throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`) + } + const info = await stat(canonical) + if (!info.isDirectory()) { + throw new Error(`workspace root "${workspaceRoot}" is not a directory`) + } + return canonical +} + +/** + * Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath` + * resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target + * must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical + * workspace. + * @param filePath - the model-supplied source path (relative or absolute). + * @param canonicalWorkspace - the already-canonicalized workspace root. + * @param maxDocumentBytes - the largest source this host will open. + * @returns the canonical path and current UTF-8 text. + * @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace. + */ +export async function readHostSource( + filePath: string, + canonicalWorkspace: string, + maxDocumentBytes: number, +): Promise { + const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath) + let canonicalPath: string + try { + canonicalPath = await realpath(requested) + } catch (error) { + throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`) + } + if (!isInside(canonicalWorkspace, canonicalPath)) { + throw new Error(`source "${filePath}" resolves outside the workspace`) + } + const info = await stat(canonicalPath) + if (!info.isFile()) { + throw new Error(`source "${filePath}" is not a regular file`) + } + if (info.size > maxDocumentBytes) { + throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) + } + const buffer = await readFile(canonicalPath) + const text = decodeUtf8Strict(buffer, filePath) + return { canonicalPath, text } +} + +/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ +function isInside(workspace: string, child: string): boolean { + if (child === workspace) return true + /* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */ + const base = workspace.endsWith(sep) ? workspace : workspace + sep + return child.startsWith(base) +} + +/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */ +function decodeUtf8Strict(buffer: Buffer, filePath: string): string { + const text = buffer.toString('utf8') + if (text.includes('�')) { + throw new Error(`source "${filePath}" is not valid UTF-8 text`) + } + return text +} + +/** Extract a message from an unknown thrown value without leaking `any`. */ +function messageOf(error: unknown): string { + /* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */ + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts new file mode 100644 index 0000000000..b9c32867da --- /dev/null +++ b/packages/lsp/lsp-local/src/index.ts @@ -0,0 +1,255 @@ +/** + * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server + * command and its extension→language-id map; load multiple instances for multiple servers. The + * provider lazily single-flights one server process per `(provider id, canonical workspace + * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later + * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and + * trusts its configured server — no sandbox confinement. + * + * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal + * unregisters from `ctx.lsp` and tears down every live server. + * @module @deepseek-ai/dsh-lsp-local + */ + +import { accessSync, constants } from 'node:fs' +import { delimiter, isAbsolute, join } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { LspProviderId } from '@deepseek-ai/dsh-lsp' +import type { + LspProvider, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +// Side-effect type import: declaration-merges `ctx.lsp` onto Context. +import type {} from '@deepseek-ai/dsh-lsp' +import { canonicalizeWorkspace } from './host.ts' +import { LspInstance } from './instance.ts' +import type { InstanceSpec } from './instance.ts' + +export { canonicalizeWorkspace, readHostSource } from './host.ts' +export { encodeMessage, MessageDecoder } from './framing.ts' +export { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' +export { LspInstance } from './instance.ts' +export { LspConnection } from './connection.ts' + +/** Cordis plugin name for loader diagnostics. */ +export const name = 'lsp-local' + +/** Services required by this plugin. */ +export const inject = ['lsp'] + +/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 +const DEFAULT_MAX_STDERR_BYTES = 1_000_000 +const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 +const DEFAULT_KILL_GRACE_MS = 2_000 + +/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +export interface Config { + /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ + providerId: string + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + killGraceMs?: number +} + +/** The resolved config after schemastery fills every default; the provider reads this shape. */ +type ResolvedConfig = Required + +export const Config: z = z.object({ + providerId: z.string().required(), + command: z.string().required(), + args: z.array(String).default([]), + env: z.dict(String).default({}), + extensionToLanguage: z.dict(String).required(), + initializationOptions: z.any().default(null), + configuration: z.any().default(null), + maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES), + maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES), + maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES), + shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), +}) + +/** + * Register a generic stdio LSP provider. Resolves the executable at load (after credential + * scrubbing) and fails before registration when it is unavailable; the process itself launches + * lazily on the first matching query. + * @param ctx - the plugin context (must inject `lsp`). + * @param config - the resolved plugin configuration (schemastery has filled every default). + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + const childEnv = buildChildEnv(resolved.env) + // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. + const executable = resolveExecutable(resolved.command, childEnv) + + const provider = new LocalLspProvider(resolved, childEnv, executable) + ctx.effect(() => { + const dispose = ctx.lsp.registerProvider(provider) + return async () => { + dispose() + await provider.disposeAll() + } + }, 'lsp-local.registerProvider') +} + +/** A pooled generic provider: one server process per canonical workspace, created on demand. */ +class LocalLspProvider implements LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + /** Single-flight map: canonical workspace realpath → the (pending) instance for it. */ + private readonly instances = new Map>() + private disposed = false + + constructor( + private readonly config: ResolvedConfig, + private readonly childEnv: Record, + private readonly executable: string, + ) { + this.id = LspProviderId(config.providerId) + this.extensionToLanguage = config.extensionToLanguage + } + + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { + /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */ + if (this.disposed) throw new Error('lsp-local provider is disposed') + const workspace = await canonicalizeWorkspace(request.workspaceRoot) + const instance = await this.instanceFor(workspace) + try { + return await instance.query(request, signal) + } finally { + // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, + // but only if the slot still holds THIS instance (a concurrent replacement must survive). + if (instance.dead) { + const slot = this.instances.get(workspace) + /* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */ + if (slot !== undefined && (await settledInstance(slot)) === instance) { + this.instances.delete(workspace) + } + } + } + } + + /** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */ + private instanceFor(workspace: string): Promise { + const existing = this.instances.get(workspace) + if (existing !== undefined) return existing + const created = Promise.resolve().then(() => this.createInstance(workspace)) + this.instances.set(workspace, created) + /* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures + surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */ + created.catch(() => { + if (this.instances.get(workspace) === created) this.instances.delete(workspace) + }) + return created + } + + private createInstance(workspace: string): LspInstance { + const spec: InstanceSpec = { + command: this.executable, + args: this.config.args, + cwd: workspace, + env: this.childEnv, + configuration: this.config.configuration, + initializationOptions: this.config.initializationOptions, + maxMessageBytes: this.config.maxMessageBytes, + maxStderrBytes: this.config.maxStderrBytes, + maxDocumentBytes: this.config.maxDocumentBytes, + shutdownTimeoutMs: this.config.shutdownTimeoutMs, + killGraceMs: this.config.killGraceMs, + } + return new LspInstance(spec) + } + + /** Dispose every live instance and block further queries. */ + async disposeAll(): Promise { + this.disposed = true + const pending = [...this.instances.values()] + this.instances.clear() + await Promise.all(pending.map(async (entry) => { + try { + const instance = await entry + await instance.dispose() + } catch { + // A never-initialized instance already rejected; nothing to tear down. + } + })) + } +} + +/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */ +async function settledInstance(slot: Promise): Promise { + try { + return await slot + } catch { + /* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */ + return undefined + } +} + +/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +function buildChildEnv(extra: Record): Record { + const scrubbed = Object.entries(process.env).filter( + ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), + ) as [string, string][] + return { ...Object.fromEntries(scrubbed), ...extra } +} + +/** + * Resolve the server executable to an absolute path: an absolute command is verified directly; a + * bare command is looked up on the child's PATH. Fails loudly when nothing is executable. + */ +function resolveExecutable(command: string, childEnv: Record): string { + if (isAbsolute(command)) { + return command + } + /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */ + const pathValue = childEnv.PATH ?? process.env.PATH ?? '' + for (const dir of pathValue.split(delimiter)) { + if (dir === '') continue + const candidate = join(dir, command) + if (isExecutableSync(candidate)) return candidate + } + throw new Error(`lsp-local: command "${command}" was not found on PATH`) +} + +/** Synchronous executable check used only at load-time resolution. */ +function isExecutableSync(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts new file mode 100644 index 0000000000..0e63e46d1e --- /dev/null +++ b/packages/lsp/lsp-local/src/instance.ts @@ -0,0 +1,293 @@ +/** + * One language-server instance: a connection plus the initialize handshake, the serialized abortable + * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One + * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single + * queue so a cancellation that fails to stop the server can terminate it without killing unrelated + * work; distinct instances run in parallel. + * @module @deepseek-ai/dsh-lsp-local/instance + */ + +import { pathToFileURL } from 'node:url' +import type { + LspOperation, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { LspConnection } from './connection.ts' +import type { ConnectionSpec } from './connection.ts' +import { readHostSource } from './host.ts' +import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' +import { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' + +/** Everything an instance needs beyond the connection spec. */ +export interface InstanceSpec extends ConnectionSpec { + /** Static `initialize` options forwarded to the server. */ + readonly initializationOptions: unknown + /** Largest source file this host will open (bytes). */ + readonly maxDocumentBytes: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). */ + readonly shutdownTimeoutMs: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ + readonly killGraceMs: number +} + +/** + * A single initialized server process. Not exported as a provider — the provider single-flights and + * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down. + */ +export class LspInstance { + private readonly connection: LspConnection + private capabilities: WireServerCapabilities | undefined + /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ + private queue: Promise = Promise.resolve() + private disposed = false + /** Set once the process closes, so the pool can synchronously skip a dead instance. */ + private processClosed = false + /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise + + /** + * @param spec - the launch, initialize, and teardown parameters. + */ + constructor(private readonly spec: InstanceSpec) { + this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) + this.ready = this.initialize() + // A handshake rejection must not surface as an unhandled rejection before the first query awaits + // it; queries attach the real handler. + this.ready.catch(() => {}) + void this.connection.closed.then(() => { this.processClosed = true }) + } + + /** Synchronous liveness check: true once the process has closed or the instance was disposed. */ + get dead(): boolean { + return this.processClosed || this.disposed + } + + /** + * Run one query through the serialized queue. + * @param request - the resolved provider query. + * @param signal - optional cancellation for this query's full lifecycle. + * @returns the normalized result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise { + const run = this.queue.then(() => this.runQuery(request, signal)) + // Keep the tail alive regardless of this query's outcome so the next caller still serializes. + this.queue = run.then(() => undefined, () => undefined) + return run + } + + private async initialize(): Promise { + const initializeResult = await this.connection.request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(this.spec.cwd).href, + workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }], + capabilities: CLIENT_CAPABILITIES, + initializationOptions: this.spec.initializationOptions, + }) as WireInitializeResult + const capabilities = initializeResult.capabilities + // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. + negotiatePositionEncoding(capabilities.positionEncoding) + this.capabilities = capabilities + this.connection.notify('initialized', {}) + } + + private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise { + if (this.disposed) throw new Error('LSP instance was disposed') + if (signal?.aborted) throw abortError(signal) + await this.ready + const capabilities = this.capabilities + /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ + if (capabilities === undefined) throw new Error('LSP instance is not initialized') + if (!supportsOperation(capabilities, request.operation)) { + throw new Error(`server does not support ${request.operation}`) + } + if (!supportsTransientOpen(capabilities.textDocumentSync)) { + throw new Error('server does not support the transient textDocument/didOpen this host requires') + } + + const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes) + const uri = pathToFileURL(source.canonicalPath).href + let opened = false + try { + if (signal?.aborted) throw abortError(signal) + this.connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, + }) + opened = true + const payload = await this.sendRequest(request.operation, uri, request.position, signal) + return this.normalize(request.operation, payload) + } finally { + if (opened) { + try { + this.connection.notify('textDocument/didClose', { textDocument: { uri } }) + } catch (error) { + /* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error' + listener, so a synchronous didClose write failure is a defensive path. */ + // A close-write failure does not replace the settled result/error, but the instance can no + // longer be trusted: invalidate it and await bounded process termination. + this.disposed = true + void this.tearDown(error instanceof Error ? error : new Error(String(error))) + /* v8 ignore stop */ + } + } + } + } + + private async sendRequest( + operation: LspOperation, + uri: string, + position: LspProviderQuery['position'], + signal?: AbortSignal, + ): Promise { + const params = { + textDocument: { uri }, + position: { line: position.line, character: position.character }, + // references always includes declarations: the caller gets no flag and impact analysis never + // omits the defining site. + ...(operation === 'references' ? { context: { includeDeclaration: true } } : {}), + } + const requestId = this.connection.peekNextId() + const send = this.connection.request(requestMethod(operation), params) + if (signal === undefined) return send + return this.raceAbort(send, requestId, signal) + } + + /** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */ + private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { + const abort = new Promise((_, reject) => { + const onAbort = (): void => { reject(abortError(signal)) } + /* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */ + if (signal.aborted) { onAbort(); return } + signal.addEventListener('abort', onAbort, { once: true }) + // Remove the abort listener once the request settles either way; the finally-promise inherits + // send's rejection, so catch it to avoid an unhandled rejection when abort already won. + send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {}) + }) + try { + return await Promise.race([send, abort]) + } catch (error) { + if (signal.aborted) this.connection.cancel(requestId) + throw error + } + } + + private normalize(operation: LspOperation, payload: unknown): LspQueryResult { + if (operation === 'hover') { + return { kind: 'hover', hover: normalizeHover(payload) } + } + return { kind: 'locations', locations: normalizeLocations(payload) } + } + + private answerServerRequest(method: string, params: unknown): Promise { + if (method === 'workspace/configuration') { + // Answer every requested item with the one static configuration value. + const record = params as { items?: unknown[] } | null + /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */ + const items = Array.isArray(record?.items) ? record.items : [] + return Promise.resolve(items.map(() => this.spec.configuration)) + } + if (LIFECYCLE_NOOP_METHODS.has(method)) { + // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic. + return Promise.resolve(null) + } + if (method === 'workspace/applyEdit') { + // This host never applies edits or runs commands. + return Promise.reject(new Error('workspace/applyEdit is not permitted by this host')) + } + return Promise.reject(new Error(`unsupported server request: ${method}`)) + } + + /** + * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting + * process close so nothing outlives disposal. + */ + async dispose(): Promise { + if (this.disposed) { + await this.connection.closed + return + } + this.disposed = true + await this.tearDown(new Error('LSP instance disposed')) + } + + private async tearDown(_reason: Error): Promise { + try { + using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') + await this.gracefulShutdown(shutdownDeadline.signal) + } catch { + // Graceful shutdown failed or timed out: fall through to signal escalation. + } + await this.forceTerminate() + } + + /** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */ + private async gracefulShutdown(signal: AbortSignal): Promise { + const shutdown = this.connection.request('shutdown', null) + await Promise.race([ + shutdown, + new Promise((_, reject) => { + /* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */ + if (signal.aborted) { reject(abortError(signal)); return } + signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true }) + }), + ]) + this.connection.notify('exit', null) + } + + /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ + private async forceTerminate(): Promise { + this.connection.terminate() + using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') + const closedInTime = await Promise.race([ + this.connection.closed.then(() => true), + new Promise((resolve) => { + /* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (graceDeadline.signal.aborted) { resolve(false); return } + graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!closedInTime) this.connection.kill() + await this.connection.closed + } +} + +/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */ +const LIFECYCLE_NOOP_METHODS = new Set([ + 'window/workDoneProgress/create', + 'client/registerCapability', + 'client/unregisterCapability', +]) + +/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ +function abortError(signal: AbortSignal): Error { + const timeout = timeoutOf(signal) + if (timeout !== undefined) return timeout + const reason: unknown = signal.reason + if (reason instanceof Error) return reason + return new Error('LSP query aborted') +} + +/** + * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and + * configuration, markdown/plaintext hover, and link support for definition/implementation. No + * dynamic registration; the server's returned capabilities are authoritative. + */ +const CLIENT_CAPABILITIES = { + general: { positionEncodings: ['utf-16'] }, + workspace: { workspaceFolders: true, configuration: true }, + textDocument: { + synchronization: { dynamicRegistration: false }, + hover: { contentFormat: ['markdown', 'plaintext'] }, + definition: { linkSupport: true }, + implementation: { linkSupport: true }, + references: {}, + }, +} as const diff --git a/packages/lsp/lsp-local/src/protocol.ts b/packages/lsp/lsp-local/src/protocol.ts new file mode 100644 index 0000000000..abceb04d71 --- /dev/null +++ b/packages/lsp/lsp-local/src/protocol.ts @@ -0,0 +1,80 @@ +/** + * The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four + * request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to + * decide transient-open support. Types only. Fields absent from a real server payload stay optional; + * the translation layer normalizes them into the seam's closed unions. + * @module @deepseek-ai/dsh-lsp-local/protocol + */ + +/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */ +export interface WirePosition { + readonly line: number + readonly character: number +} + +/** A wire range (`Range`). */ +export interface WireRange { + readonly start: WirePosition + readonly end: WirePosition +} + +/** A `Location`: a document URI plus a range. */ +export interface WireLocation { + readonly uri: string + readonly range: WireRange +} + +/** A `LocationLink`: the target uri plus the selection range to focus. */ +export interface WireLocationLink { + readonly targetUri: string + readonly targetSelectionRange: WireRange + readonly targetRange?: WireRange +} + +/** A `MarkupContent` hover body (`markdown` or `plaintext`). */ +export interface WireMarkupContent { + readonly kind: 'markdown' | 'plaintext' + readonly value: string +} + +/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */ +export interface WireMarkedStringObject { + readonly language: string + readonly value: string +} + +/** One `MarkedString`: a raw string or a language-tagged code block. */ +export type WireMarkedString = string | WireMarkedStringObject + +/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */ +export interface WireHover { + readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[] + readonly range?: WireRange +} + +/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */ +export type WireTextDocumentSyncKind = 0 | 1 | 2 + +/** The options form of `textDocumentSync` (`{ openClose, change }`). */ +export interface WireTextDocumentSyncOptions { + readonly openClose?: boolean + readonly change?: WireTextDocumentSyncKind +} + +/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */ +export type WireProviderCapability = boolean | Record | undefined + +/** The `ServerCapabilities` fields this host inspects. */ +export interface WireServerCapabilities { + readonly positionEncoding?: string + readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions + readonly definitionProvider?: WireProviderCapability + readonly referencesProvider?: WireProviderCapability + readonly implementationProvider?: WireProviderCapability + readonly hoverProvider?: WireProviderCapability +} + +/** The `initialize` result envelope. */ +export interface WireInitializeResult { + readonly capabilities: WireServerCapabilities +} diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts new file mode 100644 index 0000000000..a212d283b6 --- /dev/null +++ b/packages/lsp/lsp-local/src/translate.ts @@ -0,0 +1,210 @@ +/** + * Pure protocol translation for the local host: what the server's capabilities allow, and how its + * `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O + * or process state — every function here is a pure transform, which the fake-stdio tests pin exactly. + * @module @deepseek-ai/dsh-lsp-local/translate + */ + +import type { + LspHover, + LspLocation, + LspOperation, + LspRange, +} from '@deepseek-ai/dsh-lsp' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { + WireHover, + WireLocation, + WireLocationLink, + WireMarkedString, + WireProviderCapability, + WireRange, + WireServerCapabilities, + WireTextDocumentSyncKind, + WireTextDocumentSyncOptions, +} from './protocol.ts' + +/** + * The `textDocument/*` request method for each seam operation. + * @param operation - the seam operation to map. + * @returns the LSP request method name. + */ +export function requestMethod(operation: LspOperation): string { + switch (operation) { + case 'definition': return 'textDocument/definition' + case 'references': return 'textDocument/references' + case 'implementation': return 'textDocument/implementation' + case 'hover': return 'textDocument/hover' + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'requestMethod') + } +} + +/** The `ServerCapabilities` provider field backing each operation. */ +function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability { + switch (operation) { + case 'definition': return capabilities.definitionProvider + case 'references': return capabilities.referencesProvider + case 'implementation': return capabilities.implementationProvider + case 'hover': return capabilities.hoverProvider + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'capabilityValue') + } +} + +/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */ +function supportsCapability(value: WireProviderCapability): boolean { + if (value === undefined) return false + if (typeof value === 'boolean') return value + return true +} + +/** + * Whether the server advertises the requested operation. + * @param capabilities - the server's `initialize` capabilities. + * @param operation - the seam operation to check. + * @returns true when the corresponding provider capability is present. + */ +export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean { + return supportsCapability(capabilityValue(capabilities, operation)) +} + +/** + * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on. + * @param sync - the server's advertised `textDocumentSync` capability. + * @returns true when transient open/close is supported. + */ +export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean { + if (sync === undefined) return false + if (typeof sync === 'number') return isOpenCloseKind(sync) + return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync)) +} + +/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */ +function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { + return kind === 1 || kind === 2 +} + +/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */ +function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean { + return sync.change !== undefined && isOpenCloseKind(sync.change) +} + +/** + * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value + * other than `utf-16` is a protocol error this host does not support. + * @param encoding - the server's advertised `positionEncoding`, if any. + * @returns the string `'utf-16'`. + * @throws Error for any non-`utf-16` encoding. + */ +export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' { + if (encoding === undefined || encoding === 'utf-16') return 'utf-16' + throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`) +} + +/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */ +function toRange(range: WireRange): LspRange { + return { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + } +} + +/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */ +function isLocationLink(value: Record): boolean { + return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange) +} + +/** Whether a record is a `Location` (has string `uri` + a range). */ +function isLocation(value: Record): boolean { + return typeof value.uri === 'string' && isRange(value.range) +} + +/** Structural range guard used by both location shapes. */ +function isRange(value: unknown): value is WireRange { + if (value === null || typeof value !== 'object') return false + const range = value as Record + return isPosition(range.start) && isPosition(range.end) +} + +/** Structural position guard. */ +function isPosition(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false + const position = value as Record + return typeof position.line === 'number' && typeof position.character === 'number' +} + +/** + * Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's + * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`. + * @param payload - the raw `textDocument/definition|references|implementation` result. + * @returns the normalized locations (empty for `null`/`[]`). + * @throws Error when an element is neither a `Location` nor a `LocationLink`. + */ +export function normalizeLocations(payload: unknown): LspLocation[] { + if (payload === null || payload === undefined) return [] + const elements = Array.isArray(payload) ? payload : [payload] + const locations: LspLocation[] = [] + for (const element of elements) { + if (element === null || typeof element !== 'object') { + throw new Error('LSP navigation result contained a non-object entry') + } + const record = element as Record + if (isLocationLink(record)) { + const link = record as unknown as WireLocationLink + locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) }) + } else if (isLocation(record)) { + const location = record as unknown as WireLocation + locations.push({ uri: location.uri, range: toRange(location.range) }) + } else { + throw new Error('LSP navigation result contained neither a Location nor a LocationLink') + } + } + return locations +} + +/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */ +function renderMarkedString(value: WireMarkedString): string { + if (typeof value === 'string') return value + return `\`\`\`${value.language}\n${value.value}\n\`\`\`` +} + +/** + * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string + * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array + * joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps. + * @param payload - the raw `textDocument/hover` result. + * @returns the normalized hover, or `null` when there is no content. + * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. + */ +export function normalizeHover(payload: unknown): LspHover | null { + if (payload === null || payload === undefined) return null + if (typeof payload !== 'object') throw new Error('LSP hover result was not an object') + const hover = payload as unknown as WireHover + const contents = renderHoverContents(hover.contents) + if (contents === '') return null + const range = hover.range + return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents } +} + +/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */ +function renderHoverContents(contents: unknown): string { + if (contents === null || contents === undefined) { + throw new Error('LSP hover result had no contents') + } + if (typeof contents === 'string') return contents + if (Array.isArray(contents)) { + return contents.map(renderMarkedString).join('\n\n') + } + if (typeof contents !== 'object') { + throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + } + const record = contents as Record + if (record.kind === 'markdown' || record.kind === 'plaintext') { + return typeof record.value === 'string' ? record.value : '' + } + if (typeof record.language === 'string' && typeof record.value === 'string') { + return renderMarkedString({ language: record.language, value: record.value }) + } + throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') +} diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..0b33953ba1 --- /dev/null +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +/** + * Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and + * `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs + * one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising + * subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/` + * is absent; CI runs it after the build. + */ + +const pkgDir = fileURLToPath(new URL('..', import.meta.url)) +const seamLib = join(pkgDir, '../lsp/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +let root: string +let ws: string + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(!built)('built lib real load path (plain node)', () => { + it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => { + const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + const script = ` + const { Context } = await import('cordis') + const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') + const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'fake', + command: ${JSON.stringify(process.execPath)}, + args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], + env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }) + const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) + console.log(JSON.stringify(result)) + await ctx.fiber.dispose() + process.exit(0) + ` + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + const exitCode = await new Promise(resolve => child.on('close', resolve)) + + expect(exitCode, `stderr:\n${stderr}`).toBe(0) + const lastLine = stdout.trim().split('\n').at(-1) ?? '' + const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] } + expect(result.kind).toBe('locations') + expect(result.locations).toHaveLength(1) + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts new file mode 100644 index 0000000000..baf6fde05f --- /dev/null +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { LspConnection } from '@deepseek-ai/dsh-lsp-local' + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** A recorded server→client request the test's handler saw. */ +interface SeenRequest { method: string; params: unknown } + +let open: LspConnection[] = [] + +afterEach(async () => { + for (const conn of open) { + conn.kill() + await conn.closed + } + open = [] +}) + +/** Spawn the fixture as a raw connection, with a scripted server-request handler. */ +function connect( + env: Record, + onServerRequest: (method: string, params: unknown) => Promise = () => Promise.resolve(null), + seen?: SeenRequest[], +): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + cwd: process.cwd(), + env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + configuration: { setting: 42 }, + }, (method, params) => { + seen?.push({ method, params }) + return onServerRequest(method, params) + }) + open.push(conn) + return conn +} + +describe('LspConnection', () => { + it('completes an initialize request/response round-trip and exposes a pid', async () => { + const conn = connect({}) + const result = await conn.request('initialize', { capabilities: {} }) + expect(result).toMatchObject({ capabilities: { hoverProvider: true } }) + expect(conn.pid).toBeGreaterThan(0) + }) + + it('rejects a request when the server replies with an error', async () => { + const conn = connect({ LSP_FAKE_ERROR: '1' }) + await conn.request('initialize', { capabilities: {} }) + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) + }) + + it('answers a server workspace/configuration request from static config', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'configuration' }, + (method, params) => { + if (method === 'workspace/configuration') { + const items = (params as { items: unknown[] }).items + return Promise.resolve(items.map(() => ({ setting: 42 }))) + } + return Promise.resolve(null) + }, + seen, + ) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/configuration')) + expect(seen[0]?.method).toBe('workspace/configuration') + }) + + it('drops a server→client notification without replying', async () => { + const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' }) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + // No throw and the connection stays usable. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('sends an error response when the server-request handler rejects', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'applyEdit' }, + method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null), + seen, + ) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit')) + // The connection remains healthy after emitting the error response. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('fails all pending requests and kills the process on a framing error', async () => { + const conn = connect({ LSP_FAKE_GARBAGE: '1' }) + // The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a + // Content-Length header, so initialize still resolves. This exercises the decoder's resilience. + await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined() + }) + + it('rejects a new request issued after the process closes', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/) + }) + + it('cancel is a no-op-safe write after close', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + expect(() => { conn.cancel(1) }).not.toThrow() + }) + + it('caps the retained stderr tail', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000) + }) +}) + +/** Spawn a raw connection running an inline node script as the "server". */ +function connectScript(script: string, maxStderrBytes = 100_000): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: ['-e', script], + cwd: process.cwd(), + env: { ...process.env as Record }, + maxMessageBytes: 16_000_000, + maxStderrBytes, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + return conn +} + +describe('LspConnection edge behavior', () => { + it('fails a request when the command cannot be spawned', async () => { + const conn = new LspConnection({ + command: '/definitely/not/a/real/binary/xyz', + args: [], + cwd: process.cwd(), + env: {}, + maxMessageBytes: 1000, + maxStderrBytes: 1000, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('kills the process and fails pending requests on a framing error', async () => { + // Emit an invalid Content-Length header, corrupting the stream irrecoverably. + const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)') + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('ignores a framed non-object message', async () => { + // Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('drops a response for an unknown id', async () => { + // Emit a response for id 999 (never sent), then answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('caps the retained stderr tail at maxStderrBytes across chunks', async () => { + // Write stderr repeatedly so a later chunk arrives after the cap is already reached. + const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100) + await waitFor(() => conn.stderrTail.length >= 100) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(conn.stderrTail.length).toBe(100) + }) + + it('rejects with a fallback message when the error response has no message string', async () => { + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/) + }) + + it('rejects a pending request when the process exits mid-flight', async () => { + // Never responds, then exits shortly: the pending request must reject on close. + const conn = connectScript('setTimeout(()=>process.exit(0), 100)') + await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) + }) + + it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { + // A frame with a string id and no method: not dispatchable; the client must ignore it and still + // answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) +}) + +/** Poll a predicate until it holds or a deadline elapses. */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts new file mode 100644 index 0000000000..104b223794 --- /dev/null +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -0,0 +1,144 @@ +/** + * A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real + * `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake, + * transient open/close, request mapping, and teardown — without a real language server. + * + * Behavior is driven by env vars so one file backs many scenarios: + * - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch). + * - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full). + * - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults. + * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. + * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). + * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). + * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). + * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of + * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. + * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response. + * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply. + * + * Run: node --import tsx fixture-server.ts + */ + +const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' +const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 +const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} +const hang = process.env.LSP_FAKE_HANG === '1' +const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' +const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' +const onOpen = process.env.LSP_FAKE_ON_OPEN +const errorReply = process.env.LSP_FAKE_ERROR === '1' +const garbage = process.env.LSP_FAKE_GARBAGE === '1' + +let serverRequestId = 10_000 +const pendingServerRequests = new Map() + +function resultFor(method: string): unknown { + switch (method) { + case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) + case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) + case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) + case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + default: return null + } +} + +function envJson(name: string, fallback: unknown): unknown { + const raw = process.env[name] + return raw === undefined ? fallback : JSON.parse(raw) +} + +let buffer = Buffer.alloc(0) +process.stdin.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + for (;;) { + const sep = buffer.indexOf('\r\n\r\n') + if (sep < 0) break + const header = buffer.toString('ascii', 0, sep) + const match = /content-length:\s*(\d+)/i.exec(header) + if (!match) { buffer = buffer.subarray(sep + 4); continue } + const length = Number(match[1]) + const start = sep + 4 + if (buffer.length < start + length) break + const body = buffer.toString('utf8', start, start + length) + buffer = buffer.subarray(start + length) + handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }) + } +}) + +function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void { + const { id, method } = message + // A frame with an id but no method is the client's REPLY to a server→client request; log it. + if (method === undefined && id !== undefined && pendingServerRequests.has(id)) { + const kind = pendingServerRequests.get(id) + pendingServerRequests.delete(id) + process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`) + return + } + if (method === 'initialize') { + if (garbage) process.stdout.write('this is not a framed message\r\n') + send({ + id, + result: { + capabilities: { + positionEncoding: enc, + textDocumentSync: sync, + definitionProvider: true, + referencesProvider: true, + implementationProvider: true, + hoverProvider: true, + ...(extraCaps as Record), + }, + }, + }) + return + } + if (method === 'shutdown') { + if (noShutdown) return + send({ id, result: null }) + return + } + if (method === 'exit') { + process.exit(0) + } + if (method === 'textDocument/didOpen') { + if (crashOnOpen) process.exit(1) + if (onOpen !== undefined) emitServerRequest(onOpen) + return + } + if (method === 'textDocument/didClose' || method === 'initialized') return + if (method?.startsWith('textDocument/')) { + if (hang) return + if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } + send({ id, result: resultFor(method) }) + return + } + // Unknown request with an id: answer null so the client never stalls. + if (id !== undefined) send({ id, result: null }) +} + +/** Emit a server→client request and log the client's reply to stderr for the test to assert. */ +function emitServerRequest(kind: string): void { + if (kind === 'notification') { + send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } }) + return + } + const id = serverRequestId++ + const method = kind === 'configuration' + ? 'workspace/configuration' + : kind === 'applyEdit' + ? 'workspace/applyEdit' + : kind === 'lifecycle' + ? 'client/registerCapability' + : 'window/showMessageRequest' + const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {} + pendingServerRequests.set(id, method) + send({ id, method, params }) +} + +function send(message: Record): void { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8') + process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body])) +} + +// Keep the event loop alive. +process.stdin.resume() diff --git a/packages/lsp/lsp-local/tests/framing.spec.ts b/packages/lsp/lsp-local/tests/framing.spec.ts new file mode 100644 index 0000000000..66bca07f10 --- /dev/null +++ b/packages/lsp/lsp-local/tests/framing.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local' + +/** Frame a message the way a server would, for decoder round-trips. */ +function frame(body: string): Buffer { + return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')]) +} + +describe('encodeMessage', () => { + it('prefixes a Content-Length header with the utf-8 byte length', () => { + const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } }) + const text = buffer.toString('utf8') + const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}' + expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`) + }) +}) + +describe('MessageDecoder', () => { + it('decodes a single framed message', () => { + const decoder = new MessageDecoder(1_000) + expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }]) + }) + + it('decodes multiple messages arriving in one chunk', () => { + const decoder = new MessageDecoder(1_000) + const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')]) + expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('reassembles a message split across chunks', () => { + const decoder = new MessageDecoder(1_000) + const full = frame('{"hello":"world"}') + expect(decoder.push(full.subarray(0, 10))).toEqual([]) + expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }]) + }) + + it('handles a header split from its body', () => { + const decoder = new MessageDecoder(1_000) + const body = '{"x":1}' + expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([]) + expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }]) + }) + + it('reads a case-insensitive header and ignores other headers', () => { + const decoder = new MessageDecoder(1_000) + const body = '{"ok":true}' + const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8') + expect(decoder.push(chunk)).toEqual([{ ok: true }]) + }) + + it('rejects a body over the size limit', () => { + const decoder = new MessageDecoder(4) + expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/) + }) + + it('rejects a missing Content-Length header', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/) + }) + + it('rejects a non-numeric Content-Length', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/) + }) + + it('rejects a header block that never terminates', () => { + const decoder = new MessageDecoder(1_000) + const huge = Buffer.alloc((1 << 16) + 1, 0x41) + expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) + }) + + it('rejects a non-JSON body', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts new file mode 100644 index 0000000000..b76aa556e2 --- /dev/null +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { realpath } from 'node:fs/promises' +import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-'))) + ws = join(root, 'ws') + await mkdir(ws) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const BIG = 1_000_000 + +describe('canonicalizeWorkspace', () => { + it('returns the realpath of a directory', async () => { + expect(await canonicalizeWorkspace(ws)).toBe(ws) + }) + + it('resolves a symlinked workspace to its target so aliases share identity', async () => { + const link = join(root, 'ws-link') + await symlink(ws, link) + expect(await canonicalizeWorkspace(link)).toBe(ws) + }) + + it('rejects a missing workspace', async () => { + await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-directory workspace', async () => { + const file = join(root, 'file.txt') + await writeFile(file, 'x') + await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/) + }) +}) + +describe('readHostSource', () => { + it('reads a relative path against the workspace', async () => { + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') + const source = await readHostSource('a.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'a.ts')) + expect(source.text).toBe('const x = 1\n') + }) + + it('reads an absolute path inside the workspace', async () => { + const abs = join(ws, 'b.ts') + await writeFile(abs, 'b') + const source = await readHostSource(abs, ws, BIG) + expect(source.canonicalPath).toBe(abs) + }) + + it('accepts a source reached through a symlink that stays inside the workspace', async () => { + await mkdir(join(ws, 'real')) + await writeFile(join(ws, 'real', 'c.ts'), 'c') + await symlink(join(ws, 'real'), join(ws, 'linked')) + const source = await readHostSource('linked/c.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts')) + }) + + it('rejects a source whose canonical path escapes the workspace via symlink', async () => { + const outside = join(root, 'outside.ts') + await writeFile(outside, 'secret') + await symlink(outside, join(ws, 'escape.ts')) + await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects an absolute source outside the workspace', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects a missing source', async () => { + await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-regular source (directory)', async () => { + await mkdir(join(ws, 'dir')) + await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('treats the workspace root itself as inside, then rejects it as non-regular', async () => { + // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the + // directory then fails the regular-file check. + await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('rejects an oversized source', async () => { + await writeFile(join(ws, 'big.ts'), 'x'.repeat(100)) + await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/) + }) + + it('rejects a non-UTF-8 source', async () => { + await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) + await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts new file mode 100644 index 0000000000..da3231f133 --- /dev/null +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { LspInstance } from '@deepseek-ai/dsh-lsp-local' +import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' +import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp' + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +let root: string +let ws: string +let live: LspInstance[] = [] + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + for (const instance of live) await instance.dispose() + live = [] + await rm(root, { recursive: true, force: true }) +}) + +function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + cwd: ws, + env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + configuration: { setting: 42 }, + initializationOptions: { init: true }, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + maxDocumentBytes: 4_000_000, + shutdownTimeoutMs: 200, + killGraceMs: 200, + ...overrides, + }) + live.push(instance) + return instance +} + +function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery { + return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' } +} + +/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */ +function scriptInstance(script: string, overrides: Partial = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: ['-e', script], + cwd: ws, + env: { ...process.env as Record }, + configuration: null, + initializationOptions: null, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + maxDocumentBytes: 4_000_000, + shutdownTimeoutMs: 150, + killGraceMs: 150, + ...overrides, + }) + live.push(instance) + return instance +} + +/** An inline server that answers initialize + definition and echoes a location. */ +const RESPONDING_SERVER = + 'let b=Buffer.alloc(0);' + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + +describe('LspInstance server-request handling', () => { + it('answers workspace/configuration with the static config per item', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() }) + // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer + // keeps the query working. + await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' }) + }) + + it('accepts a lifecycle client/registerCapability request', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) + await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + }) + + it('rejects a workspace/applyEdit request but keeps serving', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) + await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + }) + + it('rejects an unknown server request but keeps serving', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) + await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + }) +}) + +describe('LspInstance query and abort', () => { + it('sends includeDeclaration for references', async () => { + const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) + await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' }) + }) + + it('rejects a query aborted before it starts', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort(new Error('pre-abort')) + await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/) + }) + + it('cancels an in-flight request on abort and rejects', async () => { + const instance = makeInstance({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + // Warm the instance first so the abort lands during the hanging request, not during startup. + const pending = instance.query(query('definition'), controller.signal) + await new Promise(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + }) + + it('rejects when the server lacks the operation capability', async () => { + const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) + await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/) + }) + + it('propagates a server error response even when a signal is supplied (not an abort)', async () => { + // A live signal is passed, but the request fails for a server reason; the catch must rethrow + // without treating it as an abort. + const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) + const controller = new AbortController() + await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/) + }) +}) + +describe('LspInstance disposal', () => { + it('is idempotent — a second dispose awaits close without error', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await instance.query(query('definition')) + await instance.dispose() + await expect(instance.dispose()).resolves.toBeUndefined() + }) + + it('rejects a query after disposal', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await instance.query(query('definition')) + await instance.dispose() + await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/) + }) + + it('reports dead after the process closes', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await instance.query(query('definition')) + await instance.dispose() + expect(instance.dead).toBe(true) + }) + + it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => { + // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it. + const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});' + const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await instance.query(query('definition')) + await expect(instance.dispose()).resolves.toBeUndefined() + }) + + it('carries a non-Error abort reason as a generic aborted error', async () => { + const instance = makeInstance({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + const pending = instance.query(query('definition'), controller.signal) + await new Promise(resolve => setTimeout(resolve, 200)) + controller.abort('a string reason, not an Error') + await expect(pending).rejects.toThrow(/aborted/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..c5631f2a95 --- /dev/null +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { deadline } from '@deepseek-ai/dsh-timeout' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config } from '@deepseek-ai/dsh-lsp-local' + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'fake', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, + extensionToLanguage: { '.ts': 'typescript' }, + ...overrides, + }) + return ctx +} + +function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest { + return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws } +} + +/** A single Location JSON pointing into the workspace. */ +function locationJson(line: number): unknown { + return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } } +} + +describe('lsp-local end to end over a fake server', () => { + it('resolves definition to normalized locations', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const result = await ctx.lsp.query(query('definition')) + expect(result).toEqual({ + kind: 'locations', + locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], + }) + await ctx.fiber.dispose() + }) + + it('maps a LocationLink for implementation', async () => { + const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } } + const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) }) + const result = await ctx.lsp.query(query('implementation')) + expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] }) + await ctx.fiber.dispose() + }) + + it('returns references (server includes the declaration)', async () => { + const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) }) + const result = await ctx.lsp.query(query('references')) + expect(result).toMatchObject({ kind: 'locations' }) + if (result.kind !== 'locations') throw new Error('expected locations') + expect(result.locations).toHaveLength(2) + await ctx.fiber.dispose() + }) + + it('normalizes a hover MarkupContent', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) }) + const result = await ctx.lsp.query(query('hover')) + expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } }) + await ctx.fiber.dispose() + }) + + it('returns an empty locations result for a null definition', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + await ctx.fiber.dispose() + }) + + it('returns a null hover for a null result', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: 'null' }) + expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null }) + await ctx.fiber.dispose() + }) + + it('rejects a non-utf-16 position encoding at initialize', async () => { + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + + it('rejects a server without transient-open sync (None)', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await ctx.fiber.dispose() + }) + + it('accepts openClose options sync', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + await ctx.fiber.dispose() + }) + + it('fails a query for an unsupported operation', async () => { + const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/) + await ctx.fiber.dispose() + }) + + it('rejects a source outside the workspace before startup', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await ctx.fiber.dispose() + }) + + it('serializes queries through one instance and runs them in order', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const results = await Promise.all([ + ctx.lsp.query(query('definition')), + ctx.lsp.query(query('definition')), + ctx.lsp.query(query('definition')), + ]) + for (const result of results) expect(result).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('aborts an in-flight query when the signal fires', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('definition'), controller.signal) + controller.abort(new Error('caller cancelled')) + await expect(pending).rejects.toThrow(/cancelled/) + await ctx.fiber.dispose() + }) + + it('classifies a timeout deadline as the abort reason', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + using d = deadline(undefined, 50, 'TEST_TIMEOUT') + await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) + await ctx.fiber.dispose() + }) + + it('fails the active query when the server crashes on open, and replaces it next query', async () => { + const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await ctx.fiber.dispose() + }) + + it('runs distinct workspaces in parallel instances', async () => { + const ws2 = join(root, 'ws2') + await mkdir(ws2) + await writeFile(join(ws2, 'a.ts'), 'const z = 2\n') + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const [r1, r2] = await Promise.all([ + ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }), + ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }), + ]) + expect(r1).toMatchObject({ kind: 'locations' }) + expect(r2).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('disposes cleanly, terminating a server that ignores shutdown', async () => { + const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 }) + await ctx.lsp.query(query('definition')) + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() + }) + + it('rejects at load when the command is not found', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'missing', + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts new file mode 100644 index 0000000000..5d4045f507 --- /dev/null +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function query(): LspQueryRequest { + return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } +} + +describe('lsp-local provider resolution', () => { + it('resolves a bare command on the child PATH and registers the provider', async () => { + // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. + const bin = join(root, 'bin') + await mkdir(bin) + const exe = join(bin, 'fake-lsp') + await writeFile(exe, '#!/bin/sh\nexit 0\n') + await chmod(exe, 0o755) + + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'onpath', + command: 'fake-lsp', + args: [], + env: { PATH: bin }, + extensionToLanguage: { '.ts': 'typescript' }, + })).resolves.toBeDefined() + await ctx.fiber.dispose() + }) + + it('skips empty PATH segments and fails when the command is absent', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'nope', + command: 'fake-lsp', + args: [], + env: { PATH: `::${join(root, 'empty')}` }, + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) + + it('rejects a query after the provider is disposed', async () => { + // Use a server that never emits results and dispose the plugin, then confirm queries are refused. + const ctx = new Context() + await ctx.plugin(Lsp) + // Grab the provider instance by registering, then dispose the whole plugin fiber. + const lsp = ctx.lsp + const fiber = await ctx.plugin(LspLocal, { + providerId: 'disp', + command: process.execPath, + args: ['-e', 'setInterval(()=>{},1000)'], + extensionToLanguage: { '.ts': 'typescript' }, + }) + await fiber.dispose() + // After disposal the provider unregistered from the seam, so selection fails as unavailable. + await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts new file mode 100644 index 0000000000..2c339075e5 --- /dev/null +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from '@deepseek-ai/dsh-lsp-local' +import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts' + +const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } } + +describe('requestMethod', () => { + it('maps each operation to its textDocument request', () => { + expect(requestMethod('definition')).toBe('textDocument/definition') + expect(requestMethod('references')).toBe('textDocument/references') + expect(requestMethod('implementation')).toBe('textDocument/implementation') + expect(requestMethod('hover')).toBe('textDocument/hover') + }) +}) + +describe('supportsOperation', () => { + it('reads the provider slot for each operation (boolean and options forms)', () => { + const caps: WireServerCapabilities = { + definitionProvider: true, + referencesProvider: { workDoneProgress: true }, + implementationProvider: false, + } + expect(supportsOperation(caps, 'definition')).toBe(true) + expect(supportsOperation(caps, 'references')).toBe(true) + expect(supportsOperation(caps, 'implementation')).toBe(false) + expect(supportsOperation(caps, 'hover')).toBe(false) + }) +}) + +describe('supportsTransientOpen', () => { + it('accepts legacy Full and Incremental enums, rejects None and absent', () => { + expect(supportsTransientOpen(1)).toBe(true) + expect(supportsTransientOpen(2)).toBe(true) + expect(supportsTransientOpen(0)).toBe(false) + expect(supportsTransientOpen(undefined)).toBe(false) + }) + + it('accepts options with openClose:true and rejects openClose:false', () => { + expect(supportsTransientOpen({ openClose: true })).toBe(true) + expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false) + }) + + it('falls back to the change enum when openClose is omitted', () => { + expect(supportsTransientOpen({ change: 1 })).toBe(true) + expect(supportsTransientOpen({ change: 0 })).toBe(false) + expect(supportsTransientOpen({})).toBe(false) + }) +}) + +describe('negotiatePositionEncoding', () => { + it('defaults an omitted encoding to utf-16', () => { + expect(negotiatePositionEncoding(undefined)).toBe('utf-16') + expect(negotiatePositionEncoding('utf-16')).toBe('utf-16') + }) + + it('rejects any other encoding', () => { + expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/) + }) +}) + +describe('normalizeLocations', () => { + it('returns empty for null and undefined', () => { + expect(normalizeLocations(null)).toEqual([]) + expect(normalizeLocations(undefined)).toEqual([]) + }) + + it('maps a single Location', () => { + expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }]) + }) + + it('maps an array of Locations', () => { + const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }]) + expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b']) + }) + + it('maps a LocationLink from targetUri + targetSelectionRange', () => { + const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE } + expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }]) + }) + + it('rejects a non-object entry', () => { + expect(() => normalizeLocations([42])).toThrow(/non-object/) + }) + + it('rejects an entry that is neither a Location nor a LocationLink', () => { + expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/) + }) + + it('rejects a Location whose range is not an object', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/) + }) + + it('rejects a Location whose range positions are malformed', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/) + }) +}) + +describe('normalizeHover', () => { + it('returns null for null', () => { + expect(normalizeHover(null)).toBeNull() + }) + + it('reads MarkupContent value and keeps a range', () => { + expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE })) + .toEqual({ contents: '# H', range: RANGE }) + }) + + it('keeps a bare string MarkedString verbatim', () => { + expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' }) + }) + + it('renders a language-tagged MarkedString object as a fenced code block', () => { + expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } })) + .toEqual({ contents: '```ts\nconst x = 1\n```' }) + }) + + it('joins a MarkedString array with one blank line', () => { + expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] })) + .toEqual({ contents: 'a\n\n```ts\nb\n```' }) + }) + + it('drops an empty-contents hover to null', () => { + expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull() + }) + + it('treats a MarkupContent with a non-string value as empty (null)', () => { + expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull() + }) + + it('rejects a non-object payload', () => { + expect(() => normalizeHover(42)).toThrow(/was not an object/) + }) + + it('rejects malformed contents', () => { + expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/) + expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/) + }) + + it('rejects a hover with no contents field', () => { + expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/) + }) + + it('ignores a malformed range and keeps the contents', () => { + expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' }) + }) +}) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts new file mode 100644 index 0000000000..ca4df620d3 --- /dev/null +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -0,0 +1,111 @@ +/** + * Keyless real-server e2e: drives the real `typescript-language-server` through the full + * `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API + * key needed — the server is a local dev dependency. This establishes one compatibility floor + * (TypeScript), not a cross-language claim. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' + +// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path. +const serverBin = join( + new URL('..', import.meta.url).pathname, + 'node_modules', + '.bin', + 'typescript-language-server', +) + +let root: string +let ws: string +let ctx: Context + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-'))) + ws = join(root, 'proj') + await mkdir(ws) + await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } })) + // A small program with a definition, a reference, an interface + implementation, and a typed value. + await writeFile(join(ws, 'shapes.ts'), [ + 'export interface Shape {', + ' area(): number', + '}', + '', + 'export class Circle implements Shape {', + ' constructor(private r: number) {}', + ' area(): number { return Math.PI * this.r * this.r }', + '}', + '', + 'export function describe(s: Shape): string {', + ' return `area=${s.area()}`', + '}', + '', + 'const c = new Circle(2)', + 'export const text = describe(c)', + '', + ].join('\n')) + + ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'typescript', + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }) +}, 60_000) + +afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + if (root) await rm(root, { recursive: true, force: true }) +}) + +/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */ +function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest { + return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws } +} + +function locations(result: LspQueryResult): readonly { uri: string }[] { + if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`) + return result.locations +} + +describe('real typescript-language-server', () => { + it('resolves the definition of a call site to its declaration', async () => { + // `export const text = describe(c)` (line 15): `describe` begins at column 21. + const result = await ctx.lsp.query(at('definition', 15, 22)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true) + }, 60_000) + + it('finds references to a symbol including its declaration', async () => { + // References to `describe` from its declaration (line 10, col 17). + const result = await ctx.lsp.query(at('references', 10, 17)) + const locs = locations(result) + // At least the declaration plus the call site. + expect(locs.length).toBeGreaterThanOrEqual(2) + }, 60_000) + + it('resolves implementations of an interface', async () => { + // Implementations of `Shape` (line 1, col 18) → Circle. + const result = await ctx.lsp.query(at('implementation', 1, 18)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + }, 60_000) + + it('returns hover information for a typed symbol', async () => { + // Hover on `Circle` in `new Circle(2)` (line 14, col 15). + const result = await ctx.lsp.query(at('hover', 14, 15)) + expect(result.kind).toBe('hover') + if (result.kind === 'hover') { + expect(result.hover).not.toBeNull() + expect(result.hover?.contents).toContain('Circle') + } + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json new file mode 100644 index 0000000000..281a8cebbf --- /dev/null +++ b/packages/lsp/lsp-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../lsp" + } + ] +} diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md new file mode 100644 index 0000000000..eac45e9f51 --- /dev/null +++ b/packages/lsp/lsp/README.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-lsp + +The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses. + +This package is the interface third of the LSP capability: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | +| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider | +| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | + +The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. + +## Service API (`ctx.lsp`) + +| Member | Semantics | +|---|---| +| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. | +| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. | + +Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector. + +Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation. + +## Vocabulary + +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself. + +## Known Limitations and Deferred Work + +- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration. +- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query. diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json new file mode 100644 index 0000000000..04de5b2506 --- /dev/null +++ b/packages/lsp/lsp/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-lsp", + "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/lsp/lsp/src/brand.ts b/packages/lsp/lsp/src/brand.ts new file mode 100644 index 0000000000..fe51a1ea00 --- /dev/null +++ b/packages/lsp/lsp/src/brand.ts @@ -0,0 +1,21 @@ +/** + * dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on + * `ctx.lsp`. The `Branded` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its + * factory together here lets `index.ts` re-export both under one name. + * @module @deepseek-ai/dsh-lsp/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque provider identity, reserved atomically with its extension mappings at registration. */ +export type LspProviderId = Branded<'LspProviderId'> + +/** + * Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at + * registration. + * @param id - the provider's stable identifier. + * @returns the same string, branded. + */ +export function LspProviderId(id: string): LspProviderId { + return id as LspProviderId +} diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts new file mode 100644 index 0000000000..e00005acde --- /dev/null +++ b/packages/lsp/lsp/src/index.ts @@ -0,0 +1,156 @@ +/** + * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query, + * order-independent selection over normalized definition/references/implementation/hover queries. + * + * A provider reserves a branded id and an exclusive set of file extensions atomically: + * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an + * invalid or conflicting registration publishes nothing, and its disposer releases every + * reservation together. Selection routes a query by the file's final extension; it never depends on + * registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch. + * @module @deepseek-ai/dsh-lsp + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { LspProviderId } from './brand.ts' +import type { + LspProvider, + LspQueryRequest, + LspQueryResult, + LspService, +} from './types.ts' + +export { LspProviderId } from './brand.ts' +export type { + LspHover, + LspLocation, + LspOperation, + LspPosition, + LspProvider, + LspProviderQuery, + LspQueryRequest, + LspQueryResult, + LspRange, + LspService, +} from './types.ts' + +declare module 'cordis' { + interface Context { + lsp: LspService + } +} + +/** + * Structured LSP failure. Extends {@link HarnessError} with a stable `code` + * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that + * callers route on instead of parsing `message`. + */ +export class LspError extends HarnessError {} + +/** + * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` → + * `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile + * (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator + * does not change the result. + * @param filePath - the source path to inspect. + * @returns the normalized extension, or `''` when there is none. + */ +export function finalExtension(filePath: string): string { + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath + const dot = base.lastIndexOf('.') + // dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension. + if (dot <= 0) return '' + return base.slice(dot).toLowerCase() +} + +/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */ +const EXTENSION_PATTERN = /^\.[^./\\]+$/ + +/** One selection route: the provider to run plus the language id to synchronize the document with. */ +interface Route { + readonly provider: LspProvider + readonly languageId: string +} + +/** + * `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared + * together per provider so a route always has a live provider. + */ +export class Lsp extends Service implements LspService { + private readonly providerIds = new Set() + private readonly routes = new Map() + + constructor(ctx: Context) { + super(ctx, 'lsp') + } + + registerProvider(provider: LspProvider): () => void { + // Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting + // registration must publish nothing (fail-loud, all-or-nothing). + const id = provider.id + if (id.trim() === '') { + throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER') + } + if (this.providerIds.has(id)) { + throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT') + } + + const entries = Object.entries(provider.extensionToLanguage) + if (entries.length === 0) { + throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER') + } + + // Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and + // `.ts`) before checking cross-provider conflicts. + const pending = new Map() + for (const [rawExt, languageId] of entries) { + const ext = normalizeExtension(rawExt) + if (!EXTENSION_PATTERN.test(ext)) { + throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER') + } + if (languageId.trim() === '') { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER') + } + if (pending.has(ext)) { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER') + } + pending.set(ext, { provider, languageId }) + } + for (const ext of pending.keys()) { + if (this.routes.has(ext)) { + throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT') + } + } + + // All checks passed: reserve id and every extension in one lifecycle controller so disposal + // releases them together. + const dispose = this.ctx.effect(function* (this: Lsp) { + this.providerIds.add(id) + for (const [ext, route] of pending) this.routes.set(ext, route) + yield () => { + this.providerIds.delete(id) + for (const ext of pending.keys()) this.routes.delete(ext) + } + }.bind(this), 'lsp.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is synchronous + // fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + async query(request: LspQueryRequest, signal?: AbortSignal): Promise { + const route = this.routes.get(finalExtension(request.filePath)) + if (route === undefined) { + throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE') + } + return route.provider.query({ ...request, languageId: route.languageId }, signal) + } +} + +/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */ +function normalizeExtension(ext: string): string { + const lower = ext.toLowerCase() + return lower.startsWith('.') ? lower : `.${lower}` +} + +export default Lsp diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts new file mode 100644 index 0000000000..0a2d73fac1 --- /dev/null +++ b/packages/lsp/lsp/src/types.ts @@ -0,0 +1,124 @@ +/** + * LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the + * {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in + * `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing + * tool owns the one-based cursor convention. The seam exposes no protocol types, process or document + * controls, or generic JSON-RPC escape hatch — only the four semantic operations. + * @module @deepseek-ai/dsh-lsp/types + */ + +import type { LspProviderId } from './brand.ts' + +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' + +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +export interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} + +/** A zero-based UTF-16 half-open range `[start, end)`. */ +export interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +export interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} + +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +export interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} + +/** One resolved location: a document URI and the range within it. */ +export interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} + +/** Normalized hover content, or `null` for no hover at the position. */ +export interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} + +/** + * The closed result union. Navigation operations (`definition`, `references`, `implementation`) + * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` + * to exhaustiveness so a new arm breaks compilation until handled. + */ +export type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'hover'; readonly hover: LspHover | null } + +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` + * always includes declarations — the provider enforces this internally; callers get no flag. + */ +export interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +export interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts new file mode 100644 index 0000000000..6746a05dc3 --- /dev/null +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Lsp, { + finalExtension, + LspError, + LspProviderId, + type LspProvider, + type LspProviderQuery, + type LspQueryResult, +} from '@deepseek-ai/dsh-lsp' + +/** A scripted provider that records the queries it receives. */ +function makeProvider( + id: string, + extensionToLanguage: Record, + result: LspQueryResult = { kind: 'locations', locations: [] }, +): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { + const seen: LspProviderQuery[] = [] + const seenSignals: (AbortSignal | undefined)[] = [] + return { + id: LspProviderId(id), + extensionToLanguage, + seen, + seenSignals, + query(request, signal) { + seen.push(request) + seenSignals.push(signal) + return Promise.resolve(result) + }, + } +} + +/** Mount an Lsp service on a fresh root context. */ +async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> { + const ctx = new Context() + await ctx.plugin(Lsp) + return { ctx, lsp: ctx.lsp as Lsp } +} + +const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } } + +function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters[0] { + return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' } +} + +describe('finalExtension', () => { + it('lowercases and keeps only the final extension', () => { + expect(finalExtension('src/Foo.TS')).toBe('.ts') + expect(finalExtension('a/b/foo.d.ts')).toBe('.ts') + expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs') + }) + + it('returns empty for no extension or a leading-dot dotfile', () => { + expect(finalExtension('Makefile')).toBe('') + expect(finalExtension('.bashrc')).toBe('') + expect(finalExtension('dir.d/file')).toBe('') + }) +}) + +describe('Lsp registration', () => { + it('registers a provider and routes a query to it, then releases on dispose', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + const dispose = lsp.registerProvider(provider) + + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) + + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { TS: 'typescript' }) + lsp.registerProvider(provider) + await lsp.query(query('a.ts')) + expect(provider.seen[0]?.languageId).toBe('typescript') + }) + + it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', {}))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a duplicate provider id (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('publishes nothing when a later extension conflicts (atomic reservation)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + // This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back. + expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + // `.py` must NOT have been reserved. + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('releases every extension and the id together on dispose', async () => { + const { lsp } = await mountLsp() + const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' })) + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + // The id is free again after release. + expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow() + }) + + it('selection is order-independent across two providers', async () => { + const { lsp } = await mountLsp() + const ts = makeProvider('ts', { '.ts': 'typescript' }, hover) + const py = makeProvider('py', { '.py': 'python' }) + lsp.registerProvider(ts) + lsp.registerProvider(py) + await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) + }) + + it('forwards the abort signal verbatim to the provider', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + lsp.registerProvider(provider) + const controller = new AbortController() + await lsp.query(query('a.ts'), controller.signal) + expect(provider.seenSignals[0]).toBe(controller.signal) + }) + + it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { + const { ctx, lsp } = await mountLsp() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + }, { inject: ['lsp'] })) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await fiber.dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('LspError carries its structured code', () => { + expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE') + }) + + it('brands a provider id without altering the string', () => { + expect(LspProviderId('ts')).toBe('ts') + }) +}) diff --git a/packages/lsp/lsp/tsconfig.json b/packages/lsp/lsp/tsconfig.json new file mode 100644 index 0000000000..7ca1556695 --- /dev/null +++ b/packages/lsp/lsp/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md new file mode 100644 index 0000000000..fdbd89d96b --- /dev/null +++ b/packages/lsp/tool-lsp/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-tool-lsp + +The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider. + +Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`. + +## The tool + +`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. + +The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. | +| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. | +| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. | + +## Model Experience + +### Prompt guidance + +**What the model sees**: One system-prompt section (order 112) positioning LSP as a precision aid, plus the tool schema below. + +**Token effect**: Fixed — the verbatim prose below is contributed once per request while the tool is enabled. + +#### Verbatim text for this context surface + +```markdown +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration. +``` + +### Tool schema + +**What the model sees**: The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp). + +**Token effect**: Fixed per request while enabled; the `timeoutMs` budget is never sent to the model. + +### Results + +**What the model sees**: File-grouped `path:line:character` location lines, or normalized hover text; capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results. + +**Token effect**: Capped by the two limits above. + +### ACP presentation + +**What the model sees**: A generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column. Rendered by the client, not sent to the model. + +**Token effect**: Zero direct token effect (client-side rendering only). + +## Known Limitations and Deferred Work + +- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers. diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json new file mode 100644 index 0000000000..4559bbd1b5 --- /dev/null +++ b/packages/lsp/tool-lsp/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-tool-lsp", + "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-lsp-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts new file mode 100644 index 0000000000..1ec48a3d74 --- /dev/null +++ b/packages/lsp/tool-lsp/src/index.ts @@ -0,0 +1,130 @@ +/** + * Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations + * (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor + * coordinates to the seam's zero-based positions, requires the session workspace with no fallback, + * caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to + * enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider. + * + * Namespace plugin (named exports, no default export). + * @module @deepseek-ai/dsh-tool-lsp + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { LspError } from '@deepseek-ai/dsh-lsp' +import type {} from '@deepseek-ai/dsh-lsp' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { + DEFAULT_MAX_HOVER_CHARS, + DEFAULT_MAX_LOCATIONS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, +} from './render.ts' +import { sessionCwd } from './session-cwd.ts' + +export { + DEFAULT_MAX_HOVER_CHARS, + DEFAULT_MAX_LOCATIONS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, + renderUri, +} from './render.ts' +export { sessionCwd } from './session-cwd.ts' + +/** Cordis plugin name for loader diagnostics. */ +export const name = 'tool-lsp' + +/** Services required by this plugin. */ +export const inject = ['tools', 'lsp', 'systemPrompt'] + +/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */ +export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000 + +/** The stable system-prompt guidance positioning LSP as a precision aid. */ +export const LSP_PROMPT_TEXT = + 'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.' + +/** Plugin configuration: result caps and the timeout budget. */ +export interface Config { + /** Largest number of rendered locations before an omission marker (default 100). */ + maxLocations?: number + /** Largest hover length in characters after normalization (default 16000). */ + maxHoverChars?: number + /** Tool-call timeout budget in ms (default 60000). */ + timeoutMs?: number +} + +export const Config: z = z.object({ + maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS), + maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS), + timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS), +}) + +type ResolvedConfig = Required + +/** + * Register the `lsp` tool and its system-prompt guidance. + * @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`). + * @param config - the resolved plugin configuration. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveInteger('maxLocations', resolved.maxLocations) + assertPositiveInteger('maxHoverChars', resolved.maxHoverChars) + assertPositiveInteger('timeoutMs', resolved.timeoutMs) + + ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT }) + + ctx.tools.register(defineTool({ + name: 'lsp', + description: + 'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.', + parameters: { + operation: { + type: 'string', + required: true, + enum: [...LSP_OPERATIONS], + description: 'definition, references, implementation, or hover.', + }, + file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' }, + line: { type: 'number', required: true, description: 'One-based line of the cursor.' }, + character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' }, + }, + timeoutMs: resolved.timeoutMs, + async execute(args, exec): Promise { + const input = parseLspArgs(args) + const workspaceRoot = sessionCwd(exec) + if (workspaceRoot === undefined) { + throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED') + } + const result = await ctx.lsp.query({ + operation: input.operation, + filePath: input.filePath, + position: input.position, + workspaceRoot, + }, exec.signal) + switch (result.kind) { + case 'locations': + return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }] + case 'hover': + return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] + } + }, + presentCall: presentLspCall, + })) +} + +/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-lsp: ${name} must be a positive integer`) + } +} diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts new file mode 100644 index 0000000000..df0913a591 --- /dev/null +++ b/packages/lsp/tool-lsp/src/render.ts @@ -0,0 +1,158 @@ +/** + * Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor + * conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and + * ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it + * depends only on the tool arguments. + * @module @deepseek-ai/dsh-tool-lsp/render + */ + +import { fileURLToPath } from 'node:url' +import { isAbsolute, relative, sep } from 'node:path' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp' + +/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */ +export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover'] + +/** Default cap on rendered locations before an omission marker is appended. */ +export const DEFAULT_MAX_LOCATIONS = 100 + +/** Default cap on hover characters (applied after normalization) before truncation is marked. */ +export const DEFAULT_MAX_HOVER_CHARS = 16_000 + +/** Validated `lsp` arguments after coordinate checks. */ +export interface LspToolInput { + readonly operation: LspOperation + readonly filePath: string + /** Zero-based UTF-16 position converted from the one-based model coordinates. */ + readonly position: LspPosition +} + +/** The raw, schema-typed argument shape. */ +export interface LspToolArgs { + readonly operation: string + readonly file_path: string + readonly line: number + readonly character: number +} + +/** + * Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are + * positive one-based integers converted to the seam's zero-based position. + * @param args - the schema-validated raw arguments. + * @returns the validated input with a zero-based position. + * @throws Error when the operation is unknown or a coordinate is not a positive integer. + */ +export function parseLspArgs(args: LspToolArgs): LspToolInput { + if (!isOperation(args.operation)) { + throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`) + } + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + const line = oneBased(args.line, 'line') + const character = oneBased(args.character, 'character') + return { + operation: args.operation, + filePath: args.file_path, + // The model counts from 1; the seam (and protocol) count from 0. + position: { line: line - 1, character: character - 1 }, + } +} + +/** Whether a string is one of the four operations. */ +function isOperation(value: string): value is LspOperation { + return (LSP_OPERATIONS as readonly string[]).includes(value) +} + +/** Validate a one-based coordinate is a positive integer. */ +function oneBased(value: number, name: string): number { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer (one-based)`) + } + return value +} + +/** + * Render a locations result grouped by file, converting each zero-based location back to a one-based + * `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path; + * outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and + * appends an omission marker when it truncates. + * @param locations - the seam's locations (possibly empty). + * @param workspaceRoot - the canonical workspace root for relativizing `file:` paths. + * @param maxLocations - the cap before truncation. + * @returns the rendered text; a distinct no-result line when there are none. + */ +export function formatLocations( + locations: readonly LspLocation[], + workspaceRoot: string, + maxLocations: number, +): string { + if (locations.length === 0) return 'No results.' + const shown = locations.slice(0, maxLocations) + const omitted = locations.length - shown.length + const grouped = new Map() + for (const location of shown) { + const path = renderUri(location.uri, workspaceRoot) + const line = location.range.start.line + 1 + const character = location.range.start.character + 1 + const entries = grouped.get(path) ?? [] + entries.push(`${path}:${line}:${character}`) + grouped.set(path, entries) + } + const lines: string[] = [] + for (const entries of grouped.values()) lines.push(...entries) + if (omitted > 0) { + lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`) + } + return lines.join('\n') +} + +/** + * Render a hover result, applying `maxHoverChars` last and marking truncation. + * @param hover - the normalized hover, or `null` for no hover. + * @param maxHoverChars - the cap applied after normalization. + * @returns the rendered hover text; a distinct no-result line for `null`. + */ +export function formatHover(hover: LspHover | null, maxHoverChars: number): string { + if (hover === null) return 'No hover information.' + const contents = hover.contents + if (contents.length <= maxHoverChars) return contents + return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).` +} + +/** + * Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative + * (inside) or absolute (outside); any other URI is returned verbatim. + * @param uri - the target URI from the seam. + * @param workspaceRoot - the canonical workspace root. + * @returns the display path or the verbatim URI. + */ +export function renderUri(uri: string, workspaceRoot: string): string { + if (!uri.startsWith('file:')) return uri + let absolute: string + try { + absolute = fileURLToPath(uri) + } catch { + // A malformed file: URI is not a path we can resolve; show it verbatim. + return uri + } + const rel = relative(workspaceRoot, absolute) + if (rel === '') return '.' + const outside = rel.startsWith('..') || isAbsolute(rel) + return outside ? absolute : rel.split(sep).join('/') +} + +/** + * ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the + * operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has + * no character, so the title preserves the column). + * @param args - the raw tool arguments. + * @returns the generic call view. + */ +export function presentLspCall(args: LspToolArgs): GenericCallView { + return { + card: 'generic', + kind: 'search', + title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`, + locations: [{ path: args.file_path, line: args.line }], + } +} diff --git a/packages/lsp/tool-lsp/src/session-cwd.ts b/packages/lsp/tool-lsp/src/session-cwd.ts new file mode 100644 index 0000000000..7fc41785de --- /dev/null +++ b/packages/lsp/tool-lsp/src/session-cwd.ts @@ -0,0 +1,19 @@ +/** + * Derive the workspace root an `lsp` call resolves against: the calling agent's per-session + * workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths. + * Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as + * `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it + * can start a server. + * @module @deepseek-ai/dsh-tool-lsp/session-cwd + */ + +import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +/** + * The session workspace cwd for this call, or `undefined` when none applies. + * @param exec - the tool-execution context; only its optional `agent` is read. + * @returns the calling agent's session cwd, or undefined for a non-agent caller. + */ +export function sessionCwd(exec: ToolExecution): string | undefined { + return exec.agent?.session.header.cwd +} diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts new file mode 100644 index 0000000000..f2e8d1c46a --- /dev/null +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import Lsp from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' + +/** + * Real-composition integration: the model-facing `lsp` tool over the real seam, the real + * `dsh-lsp-local` provider (driving an inline stdio server), and the real `dsh-timeout-policy`, all + * driven only through `ctx.tools.execute()`. Pins that a query round-trips end to end and that the + * policy's `TOOL_TIMEOUT` budget wins when the server hangs. + */ + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */ +function serverScript(hang: boolean): string { + const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + return 'let b=Buffer.alloc(0);' + + `const DEF=${definition};` + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'inline', + command: process.execPath, + args: ['-e', serverScript(hang)], + extensionToLanguage: { '.ts': 'typescript' }, + shutdownTimeoutMs: 200, + killGraceMs: 200, + }) + await ctx.plugin(TimeoutPolicy) + await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {}) + return ctx +} + +let seq = 0 +function call(ctx: Context, args: unknown) { + return ctx.tools.execute({ + callId: `int-${++seq}` as never, + name: 'lsp', + arguments: args, + agent: { session: { header: { cwd: ws } } } as never, + }) +} + +describe('tool-lsp real composition', () => { + it('round-trips a definition query through the real provider and renders a location', async () => { + const ctx = await mount(false) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + await ctx.fiber.dispose() + }, 30_000) + + it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => { + const ctx = await mount(true, 300) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 }) + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('TOOL_TIMEOUT') + await ctx.fiber.dispose() + }, 30_000) +}) diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts new file mode 100644 index 0000000000..13dbaa7b78 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -0,0 +1,24 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a + * stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the + * bare `apply`, dropping `inject` (postmortem 0001). This unwraps through the REAL + * `Loader.prototype.unwrapExports` and verifies the namespace shape survives. + */ + +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as toolLsp from '@deepseek-ai/dsh-tool-lsp' + +describe('dsh-tool-lsp real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in toolLsp).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolLsp) as Record + expect(unwrapped).toBe(toolLsp) + expect(unwrapped.name).toBe('tool-lsp') + expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts new file mode 100644 index 0000000000..53a2be5899 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { pathToFileURL } from 'node:url' +import { join } from 'node:path' +import { + DEFAULT_MAX_HOVER_CHARS, + DEFAULT_MAX_LOCATIONS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, + renderUri, +} from '@deepseek-ai/dsh-tool-lsp' +import type { LspLocation } from '@deepseek-ai/dsh-lsp' + +const WS = '/home/u/proj' + +function loc(uri: string, line: number, character = 0): LspLocation { + return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } } +} + +describe('parseLspArgs', () => { + it('accepts the four operations and converts one-based to zero-based', () => { + for (const operation of LSP_OPERATIONS) { + const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 }) + expect(input.operation).toBe(operation) + expect(input.position).toEqual({ line: 2, character: 4 }) + } + }) + + it('rejects an unknown operation', () => { + expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 })) + .toThrow(/operation must be one of/) + }) + + it('rejects a blank file_path', () => { + expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 })) + .toThrow(/file_path/) + }) + + it('rejects non-positive or non-integer coordinates', () => { + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/) + }) +}) + +describe('renderUri', () => { + it('relativizes a file: URI inside the workspace with forward slashes', () => { + const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href + expect(renderUri(uri, WS)).toBe('src/a.ts') + }) + + it('returns an absolute path for a file: URI outside the workspace', () => { + const uri = pathToFileURL('/other/lib/b.ts').href + expect(renderUri(uri, WS)).toBe('/other/lib/b.ts') + }) + + it('renders the workspace root itself as "."', () => { + expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.') + }) + + it('keeps a non-file URI verbatim', () => { + expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1') + expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class') + }) + + it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => { + // A file: URI with a host that fileURLToPath rejects falls through to the verbatim path. + expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal') + }) +}) + +describe('formatLocations', () => { + it('renders a no-result line for an empty list', () => { + expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.') + }) + + it('renders one-based path:line:character grouped by file', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS) + expect(text).toBe('a.ts:1:1\na.ts:5:3') + }) + + it('caps at maxLocations and marks the omission', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const many = Array.from({ length: 5 }, (_, i) => loc(a, i)) + const text = formatLocations(many, WS, 2) + expect(text).toContain('a.ts:1:1') + expect(text).toContain('3 more locations omitted (limit 2).') + }) + + it('uses the singular omission marker for exactly one extra', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1) + expect(text).toContain('1 more location omitted (limit 1).') + }) +}) + +describe('formatHover', () => { + it('renders a no-result line for null', () => { + expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.') + }) + + it('returns short hover verbatim', () => { + expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```') + }) + + it('caps hover at maxHoverChars and marks truncation', () => { + const text = formatHover({ contents: 'a'.repeat(50) }, 10) + expect(text.startsWith('aaaaaaaaaa\n')).toBe(true) + expect(text).toContain('hover truncated (limit 10 characters).') + }) +}) + +describe('presentLspCall', () => { + it('is a generic search card with an operation/cursor title and a line location', () => { + expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({ + card: 'generic', + kind: 'search', + title: 'LSP references a.ts:3:7', + locations: [{ path: 'a.ts', line: 3 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts new file mode 100644 index 0000000000..9cd48ed87f --- /dev/null +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' +import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp' + +/** A scripted provider recording queries; `respond` yields the result or throws. */ +function stubProvider( + respond: (request: LspProviderQuery) => LspQueryResult, + extensionToLanguage: Record = { '.ts': 'typescript' }, +): LspProvider & { seen: LspProviderQuery[] } { + const seen: LspProviderQuery[] = [] + return { + id: LspProviderId('stub'), + extensionToLanguage, + seen, + query(request) { + seen.push(request) + return Promise.resolve(respond(request)) + }, + } +} + +/** Mount the real tool stack over a real seam plus one stub provider. */ +async function mount( + provider?: LspProvider, + config: ToolLsp.Config = {}, +): Promise<{ ctx: Context }> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(Lsp) + if (provider) (ctx.lsp as Lsp).registerProvider(provider) + await ctx.plugin(ToolLsp, config) + return { ctx } +} + +let seq = 0 +/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */ +function call(ctx: Context, args: unknown, cwd: string | null = '/ws') { + return ctx.tools.execute({ + callId: `c-${++seq}` as never, + name: 'lsp', + arguments: args, + ...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {}, + }) +} + +const okLocations: LspQueryResult = { + kind: 'locations', + locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], +} + +describe('tool-lsp registration', () => { + it('registers the lsp tool and its prompt section', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + expect(ctx.tools.get('lsp')).toBeDefined() + const prompt = await ctx.systemPrompt.assemble() + const text = prompt.sections.map(s => s.text).join('\n') + expect(text).toContain(LSP_PROMPT_TEXT) + }) + + it('attaches the default timeout budget to the tool definition', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS) + }) + + it('honors a configured timeout override', async () => { + const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 }) + expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000) + }) + + it('exposes exactly the four operations in the schema enum', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } } + expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover']) + }) + + it('has no default export (namespace plugin shape)', () => { + expect((ToolLsp as { default?: unknown }).default).toBeUndefined() + }) + + it('rejects a non-positive config value at load', async () => { + await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/) + }) +}) + +describe('tool-lsp execution', () => { + it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => { + const provider = stubProvider(() => okLocations) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws') + expect(result.isError).toBe(false) + expect(provider.seen[0]).toMatchObject({ + operation: 'definition', + filePath: 'a.ts', + position: { line: 2, character: 4 }, + workspaceRoot: '/ws', + }) + }) + + it('renders locations relative to the workspace', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + }) + + it('renders hover content', async () => { + const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } }))) + const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.content[0]).toEqual({ type: 'text', text: 'number' }) + }) + + it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null) + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED') + }) + + it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { + const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('LSP_UNAVAILABLE') + }) + + it('returns a structured INVALID_ARGS on a bad operation', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('INVALID_ARGS') + }) + + it('forwards exec.signal to the seam query', async () => { + const seen: (AbortSignal | undefined)[] = [] + const provider: LspProvider = { + id: LspProviderId('sig'), + extensionToLanguage: { '.ts': 'typescript' }, + query(_request, signal) { + seen.push(signal) + return Promise.resolve(okLocations) + }, + } + const { ctx } = await mount(provider) + await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + // The timeout policy is not mounted here, so the signal is whatever the registry passes (may be + // undefined); the point is the tool threads it through without throwing. + expect(seen).toHaveLength(1) + }) + + it('presentCall renders the pending card from args', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 }) + expect(view).toEqual({ + card: 'generic', + kind: 'search', + title: 'LSP hover a.ts:2:3', + locations: [{ path: 'a.ts', line: 2 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json new file mode 100644 index 0000000000..be656effd2 --- /dev/null +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../lsp" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e0a2af98a..2cd474142b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,80 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/lsp/lsp: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/lsp/lsp-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-language-server: + specifier: ^5.0.0 + version: 5.3.0 + + packages/lsp/tool-lsp: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:^ + version: link:../lsp-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/mcp/mcp-client: dependencies: '@modelcontextprotocol/sdk': @@ -1171,7 +1245,7 @@ importers: devDependencies: cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/subagent-mock: dependencies: @@ -3612,6 +3686,18 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cordis@4.0.0-rc.6: + resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} + hasBin: true + peerDependencies: + '@cordisjs/plugin-include': ^1.0.4 + '@cordisjs/plugin-loader': ^1.0.0-rc.4 + peerDependenciesMeta: + '@cordisjs/plugin-include': + optional: true + '@cordisjs/plugin-loader': + optional: true + cordis@4.0.0-rc.7: resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} hasBin: true @@ -5330,6 +5416,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript-language-server@5.3.0: + resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} + engines: {node: '>=20'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -5471,6 +5562,20 @@ packages: jsdom: optional: true + vscode-jsonrpc@5.0.1: + resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==} + engines: {node: '>=8.0.0 || >=10.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -5876,6 +5981,14 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)': + dependencies: + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0) + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + cosmokit: 1.8.1 + js-yaml: 4.2.0 + optional: true + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': dependencies: '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) @@ -5891,6 +6004,14 @@ snapshots: js-yaml: 4.2.0 optional: true + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)': + dependencies: + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + cosmokit: 1.8.1 + optionalDependencies: + node-addon-require-builtin: 0.1.0 + optional: true + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)': dependencies: cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -7037,6 +7158,14 @@ snapshots: cookie@0.7.2: {} + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0) + cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): dependencies: '@standard-schema/spec': 1.1.0 @@ -9038,6 +9167,11 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-language-server@5.3.0: + dependencies: + vscode-jsonrpc: 5.0.1 + vscode-languageserver-protocol: 3.18.2 + typescript@6.0.3: {} unbash@3.0.0: {} @@ -9182,6 +9316,17 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@5.0.1: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-types@3.18.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index ab6b01c10d..fcacdb6ba7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -26,6 +26,8 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import Lsp from '@deepseek-ai/dsh-lsp' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' @@ -147,6 +149,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', }, + { + pkg: '@deepseek-ai/dsh-tool-lsp', + dir: 'tool-lsp', + source: 'packages/lsp/tool-lsp/src/index.ts', + requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The tool registers from the seam alone; the schema does not depend on any provider. + await ctx.plugin(Lsp) + await ctx.plugin(ToolLsp) + }, + note: + 'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e7c4ada82..a07a2fb797 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, + 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, + 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a4288b2dd3..d862549da7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,7 @@ "./packages/bash/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", + "./packages/lsp/*/src", "./packages/skill/*/src", "./packages/compact/*/src", "./packages/context/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index fc1e9f488e..18dbe5bc25 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -83,6 +83,9 @@ { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, - { "path": "./packages/mcp/mcp-client" } + { "path": "./packages/mcp/mcp-client" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } diff --git a/tsconfig.json b/tsconfig.json index 02b01678ca..d8a37f0a25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -94,6 +94,9 @@ { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, - { "path": "./packages/mcp/mcp-client" } + { "path": "./packages/mcp/mcp-client" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } From 575feaddfaa61dcc281beda215cc032a49cc3aa6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 12:08:06 +0800 Subject: [PATCH 03/48] docs(lsp): regenerate config catalog for optional lsp-local config fields --- docs/config-catalog.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a38130a739..b6b798b124 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -430,26 +430,26 @@ export interface Config { providerId: string /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string - /** Arguments passed to the executable (no shell). */ - args: string[] - /** Extra env vars merged on top of the scrubbed ambient env. */ - env: Record /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ extensionToLanguage: Record - /** Static `initialize` options forwarded to the server. */ - initializationOptions: unknown - /** Static answer to every `workspace/configuration` item. */ - configuration: unknown - /** Largest single framed message accepted from the server (bytes). */ - maxMessageBytes: number - /** Largest stderr tail retained for diagnostics (bytes). */ - maxStderrBytes: number - /** Largest source file this host will open (bytes). */ - maxDocumentBytes: number - /** Graceful `shutdown`/`exit` budget before escalation (ms). */ - shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - killGraceMs: number + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + killGraceMs?: number } ``` From 8e8f90e235dedf21872fa07e314e1d72b93bf36f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 13:11:07 +0800 Subject: [PATCH 04/48] fix(lsp): address codex review round 1 Lifecycle and safety fixes from the external review: - Observe abort while awaiting the initialize handshake, so a server that never replies can't defeat the tool-timeout signal. - On an aborted request the server won't cancel, tear the instance down after a bounded grace instead of releasing the serialized queue with work still live (prevents overlapping document lifecycles). - Re-check provider disposal after the canonicalize/read awaits so a query can't spawn an unowned server after disposeAll(). - Read the source through one open handle (stat + read on the same fd) to close the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate U+FFFD is not misclassified as invalid. - Validate and read the source BEFORE spawning a server (pre-start rejection). - Require an explicit openClose for option-form textDocumentSync. - Reject nonpositive teardown budgets and non-executable absolute commands at load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION. - Retain the stderr tail (fatal diagnostics land at exit), not the prefix. - Catalog the seam vocabulary in docs/core-data-structures/lsp.md. --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/lsp.md | 124 +++ packages/lsp/lsp-local/src/connection.ts | 5 +- packages/lsp/lsp-local/src/host.ts | 36 +- packages/lsp/lsp-local/src/index.ts | 37 +- packages/lsp/lsp-local/src/instance.ts | 83 +- packages/lsp/lsp-local/src/translate.ts | 10 +- packages/lsp/lsp-local/tests/host.spec.ts | 8 + packages/lsp/lsp-local/tests/instance.spec.ts | 89 +- packages/lsp/lsp-local/tests/provider.spec.ts | 27 + .../lsp/lsp-local/tests/translate.spec.ts | 6 +- scripts/type-equiv.manifest.json | 830 +++++++++++++++--- 12 files changed, 1038 insertions(+), 218 deletions(-) create mode 100644 docs/core-data-structures/lsp.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8926a8a998..622f91a7c9 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -28,6 +28,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | +| [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` | | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md new file mode 100644 index 0000000000..25b98a9e59 --- /dev/null +++ b/docs/core-data-structures/lsp.md @@ -0,0 +1,124 @@ +# LSP navigation + +The LSP seam — a [capability seam](../rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. + +Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) + +## Operations and coordinates + +The seam and model expose exactly four semantic queries; the union is closed, so adding one is a compile-enforced change across the seam, providers, and the tool. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing tool owns the one-based cursor convention and converts on the way in and out. + +```ts type-equiv +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +``` + +```ts type-equiv +interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} +``` + +```ts type-equiv +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} +``` + +## Request + +Every field is required: `workspaceRoot` is caller-supplied, `languageId` comes from the provider's registration (not the request), and consumers own timeouts and result limits — so no field needs implementation defaulting and there is no `resolve()` step. The provider receives the caller's request plus the derived `languageId`, which only synchronizes the transient document and never participates in selection. + +```ts type-equiv +interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} +``` + +```ts type-equiv +interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} +``` + +## Result + +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. + +```ts type-equiv +interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} +``` + +```ts type-equiv +interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} +``` + +```ts type-equiv +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'hover'; readonly hover: LspHover | null } +``` + +## Provider and service + +A provider owns a stable branded `id` and an exclusive lowercase leading-dot extension map. `registerProvider` reserves the id and every extension atomically — an invalid or conflicting registration publishes nothing — and its disposer releases all reservations. Selection is per query and order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. The seam exposes no protocol types, process/document controls, or generic JSON-RPC escape hatch. + +```ts type-equiv +interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with a stable `code` (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`) callers route on instead of parsing `message`. diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 1eeb430d22..4f1f4d9b3a 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -174,8 +174,9 @@ export class LspConnection { } private onStderr(chunk: Buffer): void { - if (this.stderr.length >= this.spec.maxStderrBytes) return - this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes) + // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just + // before it exits, so the final bounded segment is the useful one. + this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes) } private dispatch(message: unknown): void { diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index a90a703d96..8638926860 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-lsp-local/host */ -import { readFile, realpath, stat } from 'node:fs/promises' +import { open, realpath, stat } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' /** A validated source: its canonical absolute path and current UTF-8 text. */ @@ -68,16 +68,24 @@ export async function readHostSource( if (!isInside(canonicalWorkspace, canonicalPath)) { throw new Error(`source "${filePath}" resolves outside the workspace`) } - const info = await stat(canonicalPath) - if (!info.isFile()) { - throw new Error(`source "${filePath}" is not a regular file`) + // Open ONE handle after containment, then stat and read through it: a concurrent replace between + // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we + // actually read (no path-based TOCTOU). + const handle = await open(canonicalPath, 'r') + try { + const info = await handle.stat() + if (!info.isFile()) { + throw new Error(`source "${filePath}" is not a regular file`) + } + if (info.size > maxDocumentBytes) { + throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) + } + const buffer = await handle.readFile() + const text = decodeUtf8Strict(buffer, filePath) + return { canonicalPath, text } + } finally { + await handle.close() } - if (info.size > maxDocumentBytes) { - throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) - } - const buffer = await readFile(canonicalPath) - const text = decodeUtf8Strict(buffer, filePath) - return { canonicalPath, text } } /** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ @@ -88,13 +96,13 @@ function isInside(workspace: string, child: string): boolean { return child.startsWith(base) } -/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */ +/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */ function decodeUtf8Strict(buffer: Buffer, filePath: string): string { - const text = buffer.toString('utf8') - if (text.includes('�')) { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { throw new Error(`source "${filePath}" is not valid UTF-8 text`) } - return text } /** Extract a message from an unknown thrown value without leaking `any`. */ diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index b9c32867da..598254f937 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -23,7 +23,7 @@ import type { } from '@deepseek-ai/dsh-lsp' // Side-effect type import: declaration-merges `ctx.lsp` onto Context. import type {} from '@deepseek-ai/dsh-lsp' -import { canonicalizeWorkspace } from './host.ts' +import { canonicalizeWorkspace, readHostSource } from './host.ts' import { LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' @@ -110,6 +110,10 @@ export const Config: z = z.object({ */ export function apply(ctx: Context, config: Config): void { const resolved = config as ResolvedConfig + // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a + // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. + assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveInteger('killGraceMs', resolved.killGraceMs) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) @@ -124,6 +128,13 @@ export function apply(ctx: Context, config: Config): void { }, 'lsp-local.registerProvider') } +/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`lsp-local: ${name} must be a positive integer`) + } +} + /** A pooled generic provider: one server process per canonical workspace, created on demand. */ class LocalLspProvider implements LspProvider { readonly id: LspProviderId @@ -141,13 +152,26 @@ class LocalLspProvider implements LspProvider { this.extensionToLanguage = config.extensionToLanguage } + /** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */ + private isDisposed(): boolean { + return this.disposed + } + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { - /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */ - if (this.disposed) throw new Error('lsp-local provider is disposed') + /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ + if (this.isDisposed()) throw new Error('lsp-local provider is disposed') const workspace = await canonicalizeWorkspace(request.workspaceRoot) + // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized + // source must fail without leaving an idle process pooled (the pre-start rejection contract), and + // the single-handle read preserves the containment/size checks against a mid-read swap. + const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) + // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we + // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. + /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ + if (this.isDisposed()) throw new Error('lsp-local provider is disposed') const instance = await this.instanceFor(workspace) try { - return await instance.query(request, signal) + return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). @@ -185,7 +209,6 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, - maxDocumentBytes: this.config.maxDocumentBytes, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } @@ -232,6 +255,10 @@ function buildChildEnv(extra: Record): Record { */ function resolveExecutable(command: string, childEnv: Record): string { if (isAbsolute(command)) { + // Verify an absolute command too, so an unavailable one fails at load, not on the first query. + if (!isExecutableSync(command)) { + throw new Error(`lsp-local: command "${command}" is not an executable file`) + } return command } /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */ diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 0e63e46d1e..83266e9c8f 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -8,6 +8,7 @@ */ import { pathToFileURL } from 'node:url' +import { LspError } from '@deepseek-ai/dsh-lsp' import type { LspOperation, LspProviderQuery, @@ -16,7 +17,7 @@ import type { import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { LspConnection } from './connection.ts' import type { ConnectionSpec } from './connection.ts' -import { readHostSource } from './host.ts' +import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { negotiatePositionEncoding, @@ -31,8 +32,6 @@ import { export interface InstanceSpec extends ConnectionSpec { /** Static `initialize` options forwarded to the server. */ readonly initializationOptions: unknown - /** Largest source file this host will open (bytes). */ - readonly maxDocumentBytes: number /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ @@ -74,11 +73,12 @@ export class LspInstance { /** * Run one query through the serialized queue. * @param request - the resolved provider query. + * @param source - the pre-validated, already-read host source (the provider reads before spawning). * @param signal - optional cancellation for this query's full lifecycle. * @returns the normalized result. */ - query(request: LspProviderQuery, signal?: AbortSignal): Promise { - const run = this.queue.then(() => this.runQuery(request, signal)) + query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { + const run = this.queue.then(() => this.runQuery(request, source, signal)) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. this.queue = run.then(() => undefined, () => undefined) return run @@ -99,24 +99,26 @@ export class LspInstance { this.connection.notify('initialized', {}) } - private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise { + private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { if (this.disposed) throw new Error('LSP instance was disposed') if (signal?.aborted) throw abortError(signal) - await this.ready + // Observe abort during the handshake wait: a server that never answers `initialize` must not + // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). + await this.abortable(this.ready, signal) const capabilities = this.capabilities /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ if (capabilities === undefined) throw new Error('LSP instance is not initialized') if (!supportsOperation(capabilities, request.operation)) { - throw new Error(`server does not support ${request.operation}`) + throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } if (!supportsTransientOpen(capabilities.textDocumentSync)) { - throw new Error('server does not support the transient textDocument/didOpen this host requires') + throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION') } - const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes) const uri = pathToFileURL(source.canonicalPath).href let opened = false try { + /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) this.connection.notify('textDocument/didOpen', { textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, @@ -125,7 +127,10 @@ export class LspInstance { const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) } finally { - if (opened) { + // A disposed or closed instance (e.g. an aborted request whose server ignored + // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let + // the next queued query's document lifecycle overlap the still-active request. + if (opened && !this.dead) { try { this.connection.notify('textDocument/didClose', { textDocument: { uri } }) } catch (error) { @@ -141,6 +146,21 @@ export class LspInstance { } } + /** + * Await `work`, but reject as soon as `signal` aborts. The underlying `work` promise keeps its own + * handlers, so an orphaned rejection after abort is not unhandled. + */ + private abortable(work: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return work + /* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */ + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { reject(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) }) + }) + } + private async sendRequest( operation: LspOperation, uri: string, @@ -160,21 +180,33 @@ export class LspInstance { return this.raceAbort(send, requestId, signal) } - /** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */ + /** + * Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a + * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the + * instance so the still-active request cannot overlap the next queued query's document lifecycle. + */ private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { - const abort = new Promise((_, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - /* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */ - if (signal.aborted) { onAbort(); return } - signal.addEventListener('abort', onAbort, { once: true }) - // Remove the abort listener once the request settles either way; the finally-promise inherits - // send's rejection, so catch it to avoid an unhandled rejection when abort already won. - send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {}) - }) try { - return await Promise.race([send, abort]) + return await this.abortable(send, signal) } catch (error) { - if (signal.aborted) this.connection.cancel(requestId) + if (!signal.aborted) throw error + this.connection.cancel(requestId) + // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still + // running: terminate the instance (disposal awaits process close) so nothing outlives the query. + using grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') + // `settled` is true if the request finished (either outcome) before the grace elapsed. + const settled = await Promise.race([ + send.then(markSettled, markSettled), + new Promise((resolve) => { + /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (grace.signal.aborted) { resolve(false); return } + grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!settled && !this.disposed) { + this.disposed = true + await this.tearDown(abortError(signal)) + } throw error } } @@ -266,6 +298,11 @@ const LIFECYCLE_NOOP_METHODS = new Set([ 'client/unregisterCapability', ]) +/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */ +function markSettled(): boolean { + return true +} + /** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts index a212d283b6..a208c3bedf 100644 --- a/packages/lsp/lsp-local/src/translate.ts +++ b/packages/lsp/lsp-local/src/translate.ts @@ -21,7 +21,6 @@ import type { WireRange, WireServerCapabilities, WireTextDocumentSyncKind, - WireTextDocumentSyncOptions, } from './protocol.ts' /** @@ -71,13 +70,15 @@ export function supportsOperation(capabilities: WireServerCapabilities, operatio /** * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on. + * The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an + * explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false. * @param sync - the server's advertised `textDocumentSync` capability. * @returns true when transient open/close is supported. */ export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean { if (sync === undefined) return false if (typeof sync === 'number') return isOpenCloseKind(sync) - return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync)) + return sync.openClose === true } /** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */ @@ -85,11 +86,6 @@ function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { return kind === 1 || kind === 2 } -/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */ -function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean { - return sync.change !== undefined && isOpenCloseKind(sync.change) -} - /** * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value * other than `utf-16` is a protocol error this host does not support. diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index b76aa556e2..3fb9f804ac 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -102,4 +102,12 @@ describe('readHostSource', () => { await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/) }) + + it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => { + // The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed + // byte sequences are rejected). + await writeFile(join(ws, 'repl.ts'), 'const s = "�"\n') + const source = await readHostSource('repl.ts', ws, BIG) + expect(source.text).toBe('const s = "�"\n') + }) }) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index da3231f133..83c4bdfe77 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -3,9 +3,9 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' -import { LspInstance } from '@deepseek-ai/dsh-lsp-local' +import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' -import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp' +import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -38,7 +38,6 @@ function makeInstance(env: Record = {}, overrides: Partial { + const source = await readHostSource('a.ts', ws, 4_000_000) + return instance.query(query(operation), source, signal) +} + /** Build an instance whose "server" is an inline node script (for teardown-escalation control). */ function scriptInstance(script: string, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ @@ -62,7 +67,6 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - maxDocumentBytes: 4_000_000, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, @@ -87,51 +91,98 @@ describe('LspInstance server-request handling', () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() }) // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer // keeps the query working. - await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' }) }) it('accepts a lifecycle client/registerCapability request', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) - await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) - await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) - await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) }) }) describe('LspInstance query and abort', () => { it('sends includeDeclaration for references', async () => { const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) - await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' }) }) it('rejects a query aborted before it starts', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() controller.abort(new Error('pre-abort')) - await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/) + await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/) }) it('cancels an in-flight request on abort and rejects', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() // Warm the instance first so the abort lands during the hanging request, not during startup. - const pending = instance.query(query('definition'), controller.signal) + const pending = run(instance, 'definition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) }) + it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => { + // The hang server never honors cancellation, so after the bounded grace the instance must be torn + // down (its process closed) rather than left with an active request. + const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'definition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + expect(instance.dead).toBe(true) + }) + + it('resolves the cancel grace when the server honors $/cancelRequest', async () => { + // A server that answers $/cancelRequest by settling the pending request lets the grace race + // resolve via the request rather than the timeout, so the instance is NOT force-terminated. + const script = 'let b=Buffer.alloc(0),reqId=null;' + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + // The server acknowledged cancellation within grace, so the instance was not force-killed. + expect(instance.dead).toBe(false) + await instance.dispose() + }) + + it('observes abort while awaiting a slow initialize handshake', async () => { + // A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be + // observed during that wait instead of hanging the tool-timeout signal. + const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'definition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 150)) + controller.abort(new Error('handshake-abort')) + await expect(pending).rejects.toThrow(/handshake-abort/) + await instance.dispose() + }) + it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/) + await expect(run(instance, 'definition')).rejects.toThrow(/does not support definition/) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -139,28 +190,28 @@ describe('LspInstance query and abort', () => { // without treating it as an abort. const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) const controller = new AbortController() - await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/) + await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/) }) }) describe('LspInstance disposal', () => { it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await instance.query(query('definition')) + await run(instance, 'definition') await instance.dispose() await expect(instance.dispose()).resolves.toBeUndefined() }) it('rejects a query after disposal', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await instance.query(query('definition')) + await run(instance, 'definition') await instance.dispose() - await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/) + await expect(run(instance, 'definition')).rejects.toThrow(/disposed/) }) it('reports dead after the process closes', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await instance.query(query('definition')) + await run(instance, 'definition') await instance.dispose() expect(instance.dead).toBe(true) }) @@ -169,14 +220,14 @@ describe('LspInstance disposal', () => { // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it. const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});' const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await instance.query(query('definition')) + await run(instance, 'definition') await expect(instance.dispose()).resolves.toBeUndefined() }) it('carries a non-Error abort reason as a generic aborted error', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() - const pending = instance.query(query('definition'), controller.signal) + const pending = run(instance, 'definition', controller.signal) await new Promise(resolve => setTimeout(resolve, 200)) controller.abort('a string reason, not an Error') await expect(pending).rejects.toThrow(/aborted/) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 5d4045f507..20978df8d5 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -75,4 +75,31 @@ describe('lsp-local provider resolution', () => { await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await ctx.fiber.dispose() }) + + it('rejects a nonpositive teardown budget at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'bad-budget', + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + killGraceMs: 0, + })).rejects.toThrow(/killGraceMs must be a positive integer/) + await ctx.fiber.dispose() + }) + + it('rejects an absolute command that is not executable at load', async () => { + const notExe = join(root, 'not-exe.txt') + await writeFile(notExe, 'plain text, not executable') + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'abs-bad', + command: notExe, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) }) diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts index 2c339075e5..7727112089 100644 --- a/packages/lsp/lsp-local/tests/translate.spec.ts +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -47,9 +47,9 @@ describe('supportsTransientOpen', () => { expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false) }) - it('falls back to the change enum when openClose is omitted', () => { - expect(supportsTransientOpen({ change: 1 })).toBe(true) - expect(supportsTransientOpen({ change: 0 })).toBe(false) + it('requires an explicit openClose for the options form (no change-enum fallback)', () => { + expect(supportsTransientOpen({ change: 1 })).toBe(false) + expect(supportsTransientOpen({ change: 2 })).toBe(false) expect(supportsTransientOpen({})).toBe(false) }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 51d98c659e..0dcd375c8a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,150 +1,690 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, - - { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" }, - { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, - { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, - - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, - - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, - - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, - - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, - - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" }, - - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" }, - - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequestId", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalOutcome", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, - - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" }, - - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, - - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSource", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillDefinition", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillRegistration", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" }, - - { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, - - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, - - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, - - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" } + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Branded", + "source": "packages/util/brand/src/index.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Message", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "MessageSourceMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "FinishReasonMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "GenerateOptions", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ToolSchema", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmCallConfig", + "source": "packages/llm/llm/src/call-config.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Agent", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "HookContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "PromptDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationStop", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionStartSource", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "ScopeKey", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "Scoped", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "Scope", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "AssembleContext", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "PromptSection", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "ToolProviderResult", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "StreamChunk", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "TokenUsage", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "AppIdentity", + "source": "packages/llm/llm/src/attribution.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "EpochHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TodoItem", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnTriggerMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnEndReasonMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceEventType", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceOp", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceIntent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceNode", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceFoldReplacement", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceFoldResult", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "CreateSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventSurface", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionQueryErrorCode", + "source": "packages/session-query/session-query/src/config.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventReadRequest", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventWindow", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolDefinition", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaProp", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "InferArgs", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionToken", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionInput", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolGuard", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolRestriction", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionResult", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PreToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PostToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredScalar", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredSchemaType", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredSchemaNode", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredOutputSchema", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionOption", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionItem", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionRequest", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionAnswerItem", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionAnswer", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "UserInteractionProvider", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "UserInteractionError", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalRequestId", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalOutcome", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalPolicy", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalRequest", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecRequest", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecSpec", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashRunResult", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashSandboxInfo", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "CollectedOutput", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTask", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTaskRead", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "ConfinedSandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxEnforcement", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxPolicy", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "ConfinedArgv", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunRequest", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunResult", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeBindingNamespace", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeBindingFunction", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunFailure", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTarget", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTargetKey", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsVersion", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsDirEntry", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteIntent", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditRequest", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsErrorCode", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsPolicyExec", + "source": "packages/fs/fs-policy/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FileReadOutcome", + "source": "packages/fs/tool-fs/src/read-render.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillSource", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillResourceBase", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillSummary", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillCandidate", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillDefinition", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillRegistration", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillLookupOptions", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProvider", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "Config", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/compaction.md", + "symbol": "CompactionResult", + "source": "packages/compact/compact/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentCapabilities", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentResult", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStopReasonMap", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentRun", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProvider", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchSource", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchBody", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowStartRequest", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowMeta", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowResult", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowRun", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspOperation", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspPosition", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspRange", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspQueryRequest", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspProviderQuery", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspLocation", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspHover", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspQueryResult", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspProvider", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspService", + "source": "packages/lsp/lsp/src/types.ts" + } ] } From 0f3f0efd9c08b755d6127ef8b77100a34170a512 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 13:34:37 +0800 Subject: [PATCH 05/48] fix(lsp): address codex review round 2 Further lifecycle/safety hardening of the local provider: - Tear the instance down when the initialize handshake is aborted, so a poisoned pending `ready` can't make later queries for that workspace re-wait. - Make the serialized-queue wait itself abortable, so a query blocked behind hung earlier work can still observe its own timeout. - Spawn the server detached and signal the whole process group on teardown, so helper processes (e.g. tsserver) can't outlive dispose(). - Open the source with O_NOFOLLOW and cap the read at maxDocumentBytes+1, closing the symlink-swap and concurrent-grow windows the fd-based read left open. - Honor an already-aborted signal before any host I/O or startup. - Validate maxStderrBytes positive at load; surface the retained stderr tail in the "language server exited" error so a fatal startup diagnostic is visible. --- packages/lsp/lsp-local/src/connection.ts | 38 ++++++++++++++++--- packages/lsp/lsp-local/src/host.ts | 28 ++++++++++++-- packages/lsp/lsp-local/src/index.ts | 7 +++- packages/lsp/lsp-local/src/instance.ts | 32 +++++++++++++--- .../lsp/lsp-local/tests/lifecycle.spec.ts | 19 ++++++++++ 5 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 4f1f4d9b3a..272109d2c6 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -55,14 +55,17 @@ export class LspConnection { private readonly onServerRequest: (method: string, params: unknown) => Promise, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) + // `detached` puts the server in its own process group so teardown can signal the WHOLE group + // (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). this.child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.env, stdio: ['pipe', 'pipe', 'pipe'], + detached: true, }) this.closed = new Promise((resolve) => { this.child.on('close', () => { - const reason = this.closeReason ?? new Error('language server exited') + const reason = this.closeReason ?? new Error(this.exitMessage()) // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason @@ -150,14 +153,33 @@ export class LspConnection { return this.nextId } - /** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */ + /** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */ terminate(): void { - this.child.kill('SIGTERM') + this.signalGroup('SIGTERM') } - /** Send SIGKILL to the child. */ + /** Send SIGKILL to the server's process group. */ kill(): void { - this.child.kill('SIGKILL') + this.signalGroup('SIGKILL') + } + + /** + * Signal the whole process group (negative pid) so helper processes are reached; fall back to the + * direct child if the group send fails. Never throws — teardown races process exit. + */ + private signalGroup(sig: NodeJS.Signals): void { + const pid = this.child.pid + if (pid === undefined) return + try { + process.kill(-pid, sig) + } catch { + // The group is gone (already exited) or could not be signalled; try the direct child. + try { + this.child.kill(sig) + } catch { + // Already dead; nothing to signal. + } + } } private onStdout(chunk: Buffer): void { @@ -221,6 +243,12 @@ export class LspConnection { this.child.stdin.write(encodeMessage(message)) } + /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ + private exitMessage(): string { + const tail = this.stderr.trim() + return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` + } + private fail(error: Error): void { /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ if (this.closeReason === undefined) this.closeReason = error diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index 8638926860..949a1b838b 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -9,7 +9,9 @@ * @module @deepseek-ai/dsh-lsp-local/host */ +import { constants } from 'node:fs' import { open, realpath, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' /** A validated source: its canonical absolute path and current UTF-8 text. */ @@ -70,8 +72,9 @@ export async function readHostSource( } // Open ONE handle after containment, then stat and read through it: a concurrent replace between // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we - // actually read (no path-based TOCTOU). - const handle = await open(canonicalPath, 'r') + // actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a + // symlink between realpath and open (which would otherwise escape the workspace). + const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await handle.stat() if (!info.isFile()) { @@ -80,7 +83,9 @@ export async function readHostSource( if (info.size > maxDocumentBytes) { throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) } - const buffer = await handle.readFile() + // Bound the read to the cap even if the file grew after stat: read one extra byte and reject on + // overflow, so a concurrent grow cannot defeat the memory bound. + const buffer = await readCapped(handle, maxDocumentBytes, filePath) const text = decodeUtf8Strict(buffer, filePath) return { canonicalPath, text } } finally { @@ -88,6 +93,23 @@ export async function readHostSource( } } +/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */ +async function readCapped(handle: FileHandle, maxBytes: number, filePath: string): Promise { + const limit = maxBytes + 1 + const chunk = Buffer.allocUnsafe(limit) + let total = 0 + for (;;) { + const { bytesRead } = await handle.read(chunk, total, limit - total, total) + if (bytesRead === 0) break + total += bytesRead + /* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */ + if (total > maxBytes) { + throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`) + } + } + return chunk.subarray(0, total) +} + /** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ function isInside(workspace: string, child: string): boolean { if (child === workspace) return true diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 598254f937..dc6e02d3c6 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -24,7 +24,7 @@ import type { // Side-effect type import: declaration-merges `ctx.lsp` onto Context. import type {} from '@deepseek-ai/dsh-lsp' import { canonicalizeWorkspace, readHostSource } from './host.ts' -import { LspInstance } from './instance.ts' +import { abortError, LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -114,6 +114,8 @@ export function apply(ctx: Context, config: Config): void { // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) assertPositiveInteger('killGraceMs', resolved.killGraceMs) + // A nonpositive stderr cap defeats the retained-tail bound (`slice(-0)` keeps everything). + assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) @@ -160,6 +162,9 @@ class LocalLspProvider implements LspProvider { async query(request: LspProviderQuery, signal?: AbortSignal): Promise { /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') + // Honor an already-aborted signal before any host I/O so a canceled request neither reads nor + // spawns a server. + if (signal?.aborted) throw abortError(signal) const workspace = await canonicalizeWorkspace(request.workspaceRoot) // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized // source must fail without leaving an idle process pooled (the pre-start rejection contract), and diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 83266e9c8f..ecf2782a12 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -78,9 +78,14 @@ export class LspInstance { * @returns the normalized result. */ query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { - const run = this.queue.then(() => this.runQuery(request, source, signal)) - // Keep the tail alive regardless of this query's outcome so the next caller still serializes. - this.queue = run.then(() => undefined, () => undefined) + // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query + // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up + // rather than block on the shared tail forever. + const run = this.abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The + // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up + // on the wait does not deserialize the queue. + this.queue = this.queue.then(() => run).then(() => undefined, () => undefined) return run } @@ -101,10 +106,21 @@ export class LspInstance { private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { if (this.disposed) throw new Error('LSP instance was disposed') + /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) // Observe abort during the handshake wait: a server that never answers `initialize` must not // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). - await this.abortable(this.ready, signal) + // If abort wins, the handshake is still pending on a live process, so tear the instance down — + // otherwise its poisoned `ready` would make every later query for this workspace re-wait. + try { + await this.abortable(this.ready, signal) + } catch (error) { + if (signal?.aborted && !this.dead) { + this.disposed = true + await this.tearDown(abortError(signal)) + } + throw error + } const capabilities = this.capabilities /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ if (capabilities === undefined) throw new Error('LSP instance is not initialized') @@ -303,8 +319,12 @@ function markSettled(): boolean { return true } -/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ -function abortError(signal: AbortSignal): Error { +/** + * Build an abort Error carrying the signal's reason (preserving a timeout classification). + * @param signal - the aborted signal whose reason to surface. + * @returns the timeout reason if the signal carries one, else the signal's Error reason, else a generic aborted Error. + */ +export function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) if (timeout !== undefined) return timeout const reason: unknown = signal.reason diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index c5631f2a95..66a18584a1 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -151,6 +151,25 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('honors an already-aborted signal before any host I/O or startup', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort(new Error('pre-aborted')) + await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/) + await ctx.fiber.dispose() + }) + + it('surfaces the server stderr tail in the exit error', async () => { + // A server that writes to stderr then exits without answering: the query rejection carries the + // retained stderr tail so the failure is diagnosable. + const ctx = await mount({}, { + command: process.execPath, + args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], + }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/) + await ctx.fiber.dispose() + }) + it('classifies a timeout deadline as the abort reason', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) using d = deadline(undefined, 50, 'TEST_TIMEOUT') From 43d419ac5c1da0bc3e3920eb6fe089597350330c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 14:17:21 +0800 Subject: [PATCH 06/48] fix(lsp): address codex review round 3 Final review pass on the local provider: - Tear the instance down when `initialize` REJECTS (utf-8 negotiation, malformed result), not only on abort, so a permanently-rejecting `ready` is never pooled. - Use the group-aware SIGKILL on a framing failure so helpers are reached. - Validate maxMessageBytes and maxDocumentBytes positive at load alongside the other byte caps. - Fix the location renderer's outside-workspace check to match a `..` segment exactly, so an in-workspace path like `..generated/a.ts` stays relative. - Document the accepted ancestor-directory symlink-swap TOCTOU under the trusted-host model (O_NOFOLLOW guards only the final component). --- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 5 +++-- packages/lsp/lsp-local/src/index.ts | 6 +++++- packages/lsp/lsp-local/src/instance.ts | 13 +++++++------ packages/lsp/lsp-local/tests/lifecycle.spec.ts | 10 ++++++++++ packages/lsp/tool-lsp/src/render.ts | 4 +++- packages/lsp/tool-lsp/tests/render.spec.ts | 6 ++++++ 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index f9e4b32713..1424f27835 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -44,6 +44,6 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re ## Known Limitations and Deferred Work -- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. - **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 272109d2c6..6eea8aec27 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -187,9 +187,10 @@ export class LspConnection { try { messages = this.decoder.push(chunk) } catch (error) { - // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance. + // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and + // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.child.kill('SIGKILL') + this.signalGroup('SIGKILL') return } for (const message of messages) this.dispatch(message) diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index dc6e02d3c6..19d29e3618 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -114,8 +114,12 @@ export function apply(ctx: Context, config: Config): void { // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) assertPositiveInteger('killGraceMs', resolved.killGraceMs) - // A nonpositive stderr cap defeats the retained-tail bound (`slice(-0)` keeps everything). + // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound + // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad + // document cap fails later in the read path instead of at load. assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index ecf2782a12..cd73d5cb8d 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -108,16 +108,17 @@ export class LspInstance { if (this.disposed) throw new Error('LSP instance was disposed') /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) - // Observe abort during the handshake wait: a server that never answers `initialize` must not - // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). - // If abort wins, the handshake is still pending on a live process, so tear the instance down — - // otherwise its poisoned `ready` would make every later query for this workspace re-wait. + // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends + // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 + // negotiation, malformed result) without the process exiting — tear the instance down so a + // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. try { await this.abortable(this.ready, signal) } catch (error) { - if (signal?.aborted && !this.dead) { + if (!this.dead) { this.disposed = true - await this.tearDown(abortError(signal)) + /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ + await this.tearDown(error instanceof Error ? error : new Error(String(error))) } throw error } diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 66a18584a1..bf624a1c56 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -105,6 +105,16 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('does not pool a poisoned instance when initialize rejects', async () => { + // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a + // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it. + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one. + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index df0913a591..0051adc719 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -137,7 +137,9 @@ export function renderUri(uri: string, workspaceRoot: string): string { } const rel = relative(workspaceRoot, absolute) if (rel === '') return '.' - const outside = rel.startsWith('..') || isAbsolute(rel) + // A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false + // positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`). + const outside = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) return outside ? absolute : rel.split(sep).join('/') } diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 53a2be5899..88b02a1fd5 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -60,6 +60,12 @@ describe('renderUri', () => { expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.') }) + it('keeps an in-workspace path whose first segment starts with dots relative', () => { + // `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external. + const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href + expect(renderUri(uri, WS)).toBe('..generated/a.ts') + }) + it('keeps a non-file URI verbatim', () => { expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1') expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class') From 3368f7924f823d193e86a92222cec417e42c8209 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 14:57:07 +0800 Subject: [PATCH 07/48] docs(lsp): resolve ds-review-bot RFC warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the executable-resolution lifecycle contradiction to match the shipped implementation: the executable resolves eagerly at load (failing before registration if unavailable), while the server process launch stays lazy until the first query. Align both language versions. Correct two zh counterpart divergences: drop the "only" condition the Chinese text added to the English "behave best" source modality, and apply binding terminology (包(package)/包, transcript(文本记录)). --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 ++-- .../architecture/2026-07-15-lsp-capability-seam.md | 2 +- .../2026-07-15-lsp-capability-seam.zh.md | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index f8b32dcca1..33a1d6c036 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983 -2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b +2026-07-15-lsp-capability-seam.md: 77b544efbcef1f352806d0431f1ff705d2b7bd44 +2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8 diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 90cc7fce8c..77b544efbc 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -129,7 +129,7 @@ The canonical workspace `realpath` must be a directory and supplies process cwd, ## Local server lifecycle and protocol behavior -`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; resolution stays lazy and launch uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. +`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 12873d3368..dad8160f0b 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -10,11 +10,11 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。 -许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 +许多语言服务器在查询文档已按当前文本打开时表现最佳。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 ## 决策 -将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: +将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 @@ -114,7 +114,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p `dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 -`read` 工具的输出带窗口与行号,进入 transcript 且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 +`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 @@ -129,7 +129,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p ## 本地服务器生命周期与协议行为 -`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败,解析保持懒执行,启动不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 +`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 @@ -173,7 +173,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p ## 测试 -- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 +- 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 - 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 @@ -183,7 +183,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 - 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 -- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 +- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 ## 影响 From 0d8e7e98f7a4a9d239ec7af000e951c0c0909a2d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 16:42:32 +0800 Subject: [PATCH 08/48] fix(lsp): harden local provider lifecycle --- docs/core-data-structures/lsp.md | 4 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/lsp-local/src/connection.ts | 18 +++++++-- packages/lsp/lsp-local/src/framing.ts | 3 ++ packages/lsp/lsp-local/src/index.ts | 40 +++++++++++++------ packages/lsp/lsp-local/src/instance.ts | 21 +++++----- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 1 - .../lsp/lsp-local/tests/connection.spec.ts | 7 ++++ .../lsp/lsp-local/tests/fixture-server.ts | 30 ++++++++++++++ packages/lsp/lsp-local/tests/framing.spec.ts | 6 +++ packages/lsp/lsp-local/tests/instance.spec.ts | 20 ++++++++-- .../lsp/lsp-local/tests/lifecycle.spec.ts | 30 +++++++++++++- packages/lsp/lsp-local/tests/provider.spec.ts | 12 ++++++ packages/lsp/lsp/README.md | 2 +- packages/lsp/lsp/src/types.ts | 7 +++- packages/lsp/lsp/tests/lsp.spec.ts | 8 ++-- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/src/index.ts | 5 ++- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 16 ++++++++ 21 files changed, 192 insertions(+), 52 deletions(-) diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index 25b98a9e59..3067237282 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -54,7 +54,7 @@ interface LspProviderQuery extends LspQueryRequest { ## Result -A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. ```ts type-equiv interface LspLocation { @@ -76,7 +76,7 @@ interface LspHover { ```ts type-equiv type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: LspHover | null } ``` diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 33a1d6c036..69c509bae6 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 77b544efbcef1f352806d0431f1ff705d2b7bd44 -2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8 +2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12 +2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7 diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 77b544efbc..c21d2f3926 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest { } type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } interface LspProvider { @@ -77,7 +77,7 @@ interface LspService { } ``` -Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch. +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. `dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index dad8160f0b..9556ead6c2 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest { } type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } interface LspProvider { @@ -77,7 +77,7 @@ interface LspService { } ``` -映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 `dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 6eea8aec27..795fe0dbff 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -41,7 +41,7 @@ export class LspConnection { private readonly decoder: MessageDecoder private readonly pending = new Map() private nextId = 1 - private stderr = '' + private stderr = Buffer.alloc(0) private closeReason: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise @@ -90,7 +90,7 @@ export class LspConnection { /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { - return this.stderr + return this.stderr.toString('utf8') } /** @@ -199,7 +199,17 @@ export class LspConnection { private onStderr(chunk: Buffer): void { // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just // before it exits, so the final bounded segment is the useful one. - this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes) + const cap = this.spec.maxStderrBytes + if (chunk.length >= cap) { + // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. + this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) + return + } + const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) + this.stderr = Buffer.concat([ + this.stderr.subarray(this.stderr.length - retainedBytes), + chunk, + ], retainedBytes + chunk.length) } private dispatch(message: unknown): void { @@ -246,7 +256,7 @@ export class LspConnection { /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ private exitMessage(): string { - const tail = this.stderr.trim() + const tail = this.stderrTail.trim() return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` } diff --git a/packages/lsp/lsp-local/src/framing.ts b/packages/lsp/lsp-local/src/framing.ts index 8720247272..bfa6b362b5 100644 --- a/packages/lsp/lsp-local/src/framing.ts +++ b/packages/lsp/lsp-local/src/framing.ts @@ -64,6 +64,9 @@ export class MessageDecoder { } return { ready: false } } + if (separator > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) + } const headerText = this.buffer.toString('ascii', 0, separator) const contentLength = parseContentLength(headerText) if (contentLength > this.maxMessageBytes) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 19d29e3618..f1d5408f5c 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-lsp-local */ -import { accessSync, constants } from 'node:fs' +import { accessSync, constants, statSync } from 'node:fs' import { delimiter, isAbsolute, join } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' @@ -178,19 +178,23 @@ class LocalLspProvider implements LspProvider { // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - const instance = await this.instanceFor(workspace) + // Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn + // (or pool) a server solely for an operation the caller already gave up on. + if (signal?.aborted) throw abortError(signal) + let instance = await this.instanceFor(workspace) + // A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh + // one before dispatch, so this query does not have to fail on a closed connection first. One retry + // suffices — the replacement was just constructed and has not been used. + if (instance.dead) { + await this.evictIfCurrent(workspace, instance) + instance = await this.instanceFor(workspace) + } try { return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) { - const slot = this.instances.get(workspace) - /* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */ - if (slot !== undefined && (await settledInstance(slot)) === instance) { - this.instances.delete(workspace) - } - } + if (instance.dead) await this.evictIfCurrent(workspace, instance) } } @@ -208,6 +212,15 @@ class LocalLspProvider implements LspProvider { return created } + /** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */ + private async evictIfCurrent(workspace: string, instance: LspInstance): Promise { + const slot = this.instances.get(workspace) + /* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */ + if (slot !== undefined && (await settledInstance(slot)) === instance) { + this.instances.delete(workspace) + } + } + private createInstance(workspace: string): LspInstance { const spec: InstanceSpec = { command: this.executable, @@ -265,7 +278,7 @@ function buildChildEnv(extra: Record): Record { function resolveExecutable(command: string, childEnv: Record): string { if (isAbsolute(command)) { // Verify an absolute command too, so an unavailable one fails at load, not on the first query. - if (!isExecutableSync(command)) { + if (!isExecutableFileSync(command)) { throw new Error(`lsp-local: command "${command}" is not an executable file`) } return command @@ -275,14 +288,15 @@ function resolveExecutable(command: string, childEnv: Record): s for (const dir of pathValue.split(delimiter)) { if (dir === '') continue const candidate = join(dir, command) - if (isExecutableSync(candidate)) return candidate + if (isExecutableFileSync(candidate)) return candidate } throw new Error(`lsp-local: command "${command}" was not found on PATH`) } -/** Synchronous executable check used only at load-time resolution. */ -function isExecutableSync(path: string): boolean { +/** Synchronous regular-file and executable check used only at load-time resolution. */ +function isExecutableFileSync(path: string): boolean { try { + if (!statSync(path).isFile()) return false accessSync(path, constants.X_OK) return true } catch { diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index cd73d5cb8d..8bf2e48c5b 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -169,7 +169,7 @@ export class LspInstance { */ private abortable(work: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return work - /* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */ + /* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */ if (signal.aborted) return Promise.reject(abortError(signal)) return new Promise((resolve, reject) => { const onAbort = (): void => { reject(abortError(signal)) } @@ -232,7 +232,10 @@ export class LspInstance { if (operation === 'hover') { return { kind: 'hover', hover: normalizeHover(payload) } } - return { kind: 'locations', locations: normalizeLocations(payload) } + // `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning), + // and every `file:` location URI is relative to it — so it is the root a caller must relativize + // display paths against, not the request's possibly-symlinked workspaceRoot. + return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd } } private answerServerRequest(method: string, params: unknown): Promise { @@ -271,24 +274,18 @@ export class LspInstance { try { using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') await this.gracefulShutdown(shutdownDeadline.signal) + return } catch { // Graceful shutdown failed or timed out: fall through to signal escalation. } await this.forceTerminate() } - /** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */ + /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ private async gracefulShutdown(signal: AbortSignal): Promise { - const shutdown = this.connection.request('shutdown', null) - await Promise.race([ - shutdown, - new Promise((_, reject) => { - /* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */ - if (signal.aborted) { reject(abortError(signal)); return } - signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true }) - }), - ]) + await this.abortable(this.connection.request('shutdown', null), signal) this.connection.notify('exit', null) + await this.abortable(this.connection.closed, signal) } /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 0b33953ba1..cea1f0d189 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -55,7 +55,6 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) await ctx.fiber.dispose() - process.exit(0) ` const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index baf6fde05f..25ac9f2971 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -190,6 +190,13 @@ describe('LspConnection edge behavior', () => { expect(conn.stderrTail.length).toBe(100) }) + it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => { + const conn = connectScript('process.stderr.write("😀😀")', 4) + await conn.closed + expect(conn.stderrTail).toBe('😀') + expect(Buffer.byteLength(conn.stderrTail)).toBe(4) + }) + it('rejects with a fallback message when the error response has no message string', async () => { const script = 'let b=Buffer.alloc(0);' + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 104b223794..1654cb820d 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -10,6 +10,9 @@ * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). + * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request, + * simulating a server that dies while idle so the pool holds a dead instance (eviction test). + * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. @@ -19,11 +22,16 @@ * Run: node --import tsx fixture-server.ts */ +import { appendFileSync } from 'node:fs' + const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} const hang = process.env.LSP_FAKE_HANG === '1' const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' +const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' +const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) +const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' const onOpen = process.env.LSP_FAKE_ON_OPEN const errorReply = process.env.LSP_FAKE_ERROR === '1' @@ -32,6 +40,11 @@ const garbage = process.env.LSP_FAKE_GARBAGE === '1' let serverRequestId = 10_000 const pendingServerRequests = new Map() +process.on('SIGTERM', () => { + markExit('TERM') + process.exit(0) +}) + function resultFor(method: string): unknown { switch (method) { case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) @@ -98,6 +111,15 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul return } if (method === 'exit') { + markExit('EXIT') + if (exitDelayMs > 0) { + setTimeout(() => { + markExit('CLEAN') + process.exit(0) + }, exitDelayMs) + return + } + markExit('CLEAN') process.exit(0) } if (method === 'textDocument/didOpen') { @@ -110,12 +132,20 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (hang) return if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } send({ id, result: resultFor(method) }) + // Simulate an idle death: answer this request, then exit before the next one arrives so the pool + // is left holding a dead instance. + if (exitAfterReply) setTimeout(() => process.exit(0), 20) return } // Unknown request with an id: answer null so the client never stalls. if (id !== undefined) send({ id, result: null }) } +/** Append one teardown event when the fixture is configured to expose process ordering. */ +function markExit(event: string): void { + if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) +} + /** Emit a server→client request and log the client's reply to stderr for the test to assert. */ function emitServerRequest(kind: string): void { if (kind === 'notification') { diff --git a/packages/lsp/lsp-local/tests/framing.spec.ts b/packages/lsp/lsp-local/tests/framing.spec.ts index 66bca07f10..a197b2c0ca 100644 --- a/packages/lsp/lsp-local/tests/framing.spec.ts +++ b/packages/lsp/lsp-local/tests/framing.spec.ts @@ -69,6 +69,12 @@ describe('MessageDecoder', () => { expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) }) + it('rejects an oversized header block that includes its terminator', () => { + const decoder = new MessageDecoder(1_000) + const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii') + expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/) + }) + it('rejects a non-JSON body', () => { const decoder = new MessageDecoder(1_000) expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 83c4bdfe77..52fc751754 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' @@ -96,17 +96,17 @@ describe('LspInstance server-request handling', () => { it('accepts a lifecycle client/registerCapability request', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) }) @@ -195,6 +195,18 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { + it('lets a server finish protocol exit before signal escalation', async () => { + const marker = join(root, 'graceful-exit.log') + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_EXIT_DELAY_MS: '75', + LSP_FAKE_EXIT_MARKER: marker, + }, { shutdownTimeoutMs: 500 }) + await run(instance, 'definition') + await instance.dispose() + expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') + }) + it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) await run(instance, 'definition') diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index bf624a1c56..78f1a8e419 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -59,6 +59,7 @@ describe('lsp-local end to end over a fake server', () => { expect(result).toEqual({ kind: 'locations', locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], + resolvedWorkspaceRoot: ws, }) await ctx.fiber.dispose() }) @@ -89,7 +90,7 @@ describe('lsp-local end to end over a fake server', () => { it('returns an empty locations result for a null definition', async () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -123,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => { it('accepts openClose options sync', async () => { const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -195,6 +196,31 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { + // The first query succeeds, then the server exits before the second arrives, leaving a dead + // instance in the pool. The next query must evict-and-replace it and still succeed, rather than + // failing once on the closed connection first. + const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + // Wait past the fixture's post-reply exit so the pooled instance is observably dead. + await new Promise(resolve => setTimeout(resolve, 60)) + expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('does not spawn a server when the signal aborts during source read', async () => { + // Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource + // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance. + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('definition'), controller.signal) + controller.abort(new Error('mid-read cancel')) + await expect(pending).rejects.toThrow(/mid-read cancel/) + // A subsequent live query still works, proving no half-created instance poisoned the pool. + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await ctx.fiber.dispose() + }) + it('runs distinct workspaces in parallel instances', async () => { const ws2 = join(root, 'ws2') await mkdir(ws2) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 20978df8d5..53f9a88369 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -102,4 +102,16 @@ describe('lsp-local provider resolution', () => { })).rejects.toThrow(/is not an executable file/) await ctx.fiber.dispose() }) + + it('rejects an executable directory as a command at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'abs-directory', + command: ws, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) }) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index eac45e9f51..8e51d0653a 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner ## Vocabulary -`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. ## Model Experience diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts index 0a2d73fac1..d1ae71dccd 100644 --- a/packages/lsp/lsp/src/types.ts +++ b/packages/lsp/lsp/src/types.ts @@ -76,9 +76,14 @@ export interface LspHover { * The closed result union. Navigation operations (`definition`, `references`, `implementation`) * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` * to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. */ export type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: LspHover | null } /** diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 6746a05dc3..8561891df1 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -13,7 +13,7 @@ import Lsp, { function makeProvider( id: string, extensionToLanguage: Record, - result: LspQueryResult = { kind: 'locations', locations: [] }, + result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }, ): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { const seen: LspProviderQuery[] = [] const seenSignals: (AbortSignal | undefined)[] = [] @@ -63,7 +63,7 @@ describe('Lsp registration', () => { const provider = makeProvider('ts', { '.ts': 'typescript' }) const dispose = lsp.registerProvider(provider) - await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) dispose() @@ -148,7 +148,7 @@ describe('Lsp registration', () => { const py = makeProvider('py', { '.py': 'python' }) lsp.registerProvider(ts) lsp.registerProvider(py) - await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) }) @@ -172,7 +172,7 @@ describe('Lsp registration', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) }, { inject: ['lsp'] })) - await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) await fiber.dispose() await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) }) diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index fdbd89d96b..179405c71e 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In `lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. -The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. +The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. ## Configuration diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 1ec48a3d74..3a37b7b6ed 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -113,7 +113,10 @@ export function apply(ctx: Context, config: Config): void { }, exec.signal) switch (result.kind) { case 'locations': - return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }] + // Relativize against the provider's canonical workspace root (which its file: URIs are + // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every + // in-workspace location as external and render it as an absolute path. + return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }] case 'hover': return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] } diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 9cd48ed87f..577fa07396 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -51,6 +51,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') { const okLocations: LspQueryResult = { kind: 'locations', locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/ws', } describe('tool-lsp registration', () => { @@ -107,6 +108,21 @@ describe('tool-lsp execution', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) + it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => { + // A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's + // location URIs are under. Relativizing against the alias would misclassify the location as + // external and print an absolute path; the tool must use resolvedWorkspaceRoot. + const provider = stubProvider(() => ({ + kind: 'locations', + locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/real/ws', + })) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') + expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' }) + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + }) + it('renders hover content', async () => { const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } }))) const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws') From a63c55eb0053cadcea8a6987a82d8e367ebda766 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 17:31:20 +0800 Subject: [PATCH 09/48] refactor(lsp): configure local servers together --- docs/config-catalog.md | 12 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/README.md | 2 +- packages/lsp/lsp-local/README.md | 14 ++- packages/lsp/lsp-local/src/index.ts | 105 +++++++++++------- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 13 ++- .../lsp/lsp-local/tests/lifecycle.spec.ts | 49 ++++++-- packages/lsp/lsp-local/tests/provider.spec.ts | 79 ++++++++++--- .../lsp-local/tests/typescript-server.e2e.ts | 11 +- packages/lsp/lsp/README.md | 2 +- .../lsp/tool-lsp/tests/integration.spec.ts | 15 ++- 13 files changed, 213 insertions(+), 101 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b6b798b124..636713146c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -424,10 +424,14 @@ Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm Requires: `lsp` ```ts config-catalog -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +/** Plugin configuration: provider id → local language-server configuration. */ export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} + +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -453,7 +457,7 @@ export interface Config { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 69c509bae6..f063dae8cf 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12 -2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7 +2026-07-15-lsp-capability-seam.md: 500d861f60bcd238d31defaa90a3e2495a05e767 +2026-07-15-lsp-capability-seam.zh.md: b181987f707563e3115e64313250859a4238c4ee diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index c21d2f3926..500d861f60 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -17,7 +17,7 @@ Many language servers behave best when the queried document is opened with curre Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: 1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. -2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping. 3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. `dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. @@ -79,7 +79,7 @@ interface LspService { Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. -`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. ## Model-facing contract diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 9556ead6c2..b181987f70 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -17,7 +17,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 -2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。 3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 `dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的契约 diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 57a1f6b8d9..f57eed8ae0 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -5,7 +5,7 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | Package | Role | ctx key | |---|---|---| | `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | -| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) | +| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | | `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 1424f27835..75b0595626 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -1,21 +1,23 @@ # @deepseek-ai/dsh-lsp-local -A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays. +A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays. Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does -- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration -| Key | Default | Meaning | +The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape: + +| Server key | Default | Meaning | |---|---|---| -| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. | | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | | `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | @@ -28,7 +30,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | | `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | -The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query. +`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. ## Protocol behavior @@ -46,4 +48,4 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re - **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. -- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. +- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index f1d5408f5c..07e1cbed5a 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -1,10 +1,10 @@ /** - * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server - * command and its extension→language-id map; load multiple instances for multiple servers. The - * provider lazily single-flights one server process per `(provider id, canonical workspace - * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later - * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and - * trusts its configured server — no sandbox confinement. + * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table + * of server commands and registers one isolated provider for each entry. Every provider lazily + * single-flights one server process per canonical workspace realpath, serves transient-open queries + * through it, and evicts a crashed process so a later query can replace it. Providers read sources + * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no + * sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * unregisters from `ctx.lsp` and tears down every live server. @@ -55,10 +55,8 @@ const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 const DEFAULT_KILL_GRACE_MS = 2_000 -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ -export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -83,11 +81,16 @@ export interface Config { killGraceMs?: number } -/** The resolved config after schemastery fills every default; the provider reads this shape. */ -type ResolvedConfig = Required +/** Plugin configuration: provider id → local language-server configuration. */ +export interface Config { + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} -export const Config: z = z.object({ - providerId: z.string().required(), +/** One server config after schemastery fills every default. */ +type ResolvedServerConfig = Required + +const LspLocalServerConfig: z = z.object({ command: z.string().required(), args: z.array(String).default([]), env: z.dict(String).default({}), @@ -101,43 +104,66 @@ export const Config: z = z.object({ killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), }) +export const Config: z = z.object({ + servers: z.dict(LspLocalServerConfig).required(), +}) + /** - * Register a generic stdio LSP provider. Resolves the executable at load (after credential - * scrubbing) and fails before registration when it is unavailable; the process itself launches - * lazily on the first matching query. + * Register the configured stdio LSP providers. Resolves every executable at load (after credential + * scrubbing) before publishing any provider; each process launches lazily on its first matching + * query. * @param ctx - the plugin context (must inject `lsp`). * @param config - the resolved plugin configuration (schemastery has filled every default). */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const entries = Object.entries(config.servers) + if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server') + + // Resolve every server-local setting before registration so a bad later command or bound cannot + // publish an earlier provider. Registry-level mapping conflicts are rolled back below. + const providers = entries.map(([providerId, rawConfig]) => { + if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings') + const resolved = rawConfig as ResolvedServerConfig + validateServerConfig(providerId, resolved) + const childEnv = buildChildEnv(resolved.env) + const executable = resolveExecutable(resolved.command, childEnv) + return new LocalLspProvider(providerId, resolved, childEnv, executable) + }) + + ctx.effect(() => { + const disposers: Array<() => void> = [] + try { + for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return async () => { + // Remove every route before process teardown so no new query can enter a draining provider. + for (const dispose of disposers.reverse()) dispose() + await Promise.all(providers.map(provider => provider.disposeAll())) + } + }, 'lsp-local.registerProviders') +} + +/** Validate one resolved server entry before any provider in the table is registered. */ +function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. - assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveInteger('killGraceMs', resolved.killGraceMs) + assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs) // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad // document cap fails later in the read path instead of at load. - assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) - assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes) - assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes) - const childEnv = buildChildEnv(resolved.env) - // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. - const executable = resolveExecutable(resolved.command, childEnv) - - const provider = new LocalLspProvider(resolved, childEnv, executable) - ctx.effect(() => { - const dispose = ctx.lsp.registerProvider(provider) - return async () => { - dispose() - await provider.disposeAll() - } - }, 'lsp-local.registerProvider') + assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) } /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ -function assertPositiveInteger(name: string, value: number): void { +function assertPositiveInteger(providerId: string, name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { - throw new Error(`lsp-local: ${name} must be a positive integer`) + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`) } } @@ -150,11 +176,12 @@ class LocalLspProvider implements LspProvider { private disposed = false constructor( - private readonly config: ResolvedConfig, + providerId: string, + private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, ) { - this.id = LspProviderId(config.providerId) + this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage } diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index cea1f0d189..799069c0fd 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -46,11 +46,14 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'fake', - command: ${JSON.stringify(process.execPath)}, - args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], - env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + fake: { + command: ${JSON.stringify(process.execPath)}, + args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], + env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, }) const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 78f1a8e419..3d06b7576b 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' -import type { Config } from '@deepseek-ai/dsh-lsp-local' +import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -28,17 +28,23 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */ -async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { - const ctx = new Context() - await ctx.plugin(Lsp) - await ctx.plugin(LspLocal, { - providerId: 'fake', +/** One fake stdio server entry with optional behavior and host-bound overrides. */ +function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { + return { command: process.execPath, args: ['--import', tsxLoader, fixtureServer], env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, extensionToLanguage: { '.ts': 'typescript' }, ...overrides, + } +} + +/** Mount the real seam + lsp-local plugin driving one fake server. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { fake: fakeServer(fakeEnv, overrides) }, }) return ctx } @@ -53,6 +59,24 @@ function locationJson(line: number): unknown { } describe('lsp-local end to end over a fake server', () => { + it('routes different extensions to independent configured servers', async () => { + await writeFile(join(ws, 'a.py'), 'x = 1\n') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), + python: fakeServer( + { LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) }, + { extensionToLanguage: { '.py': 'python' } }, + ), + }, + }) + expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } }) + expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } }) + await ctx.fiber.dispose() + }) + it('resolves definition to normalized locations', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const result = await ctx.lsp.query(query('definition')) @@ -245,10 +269,13 @@ describe('lsp-local end to end over a fake server', () => { const ctx = new Context() await ctx.plugin(Lsp) await expect(ctx.plugin(LspLocal, { - providerId: 'missing', - command: 'definitely-not-a-real-lsp-binary-xyz', - args: [], - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + missing: { + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, })).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 53f9a88369..65a22bfb5b 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' let root: string let ws: string @@ -24,6 +25,11 @@ function query(): LspQueryRequest { return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } } +/** Wrap one server entry in the plugin's named server table. */ +function config(providerId: string, server: LspLocalServerConfig): Config { + return { servers: { [providerId]: server } } +} + describe('lsp-local provider resolution', () => { it('resolves a bare command on the child PATH and registers the provider', async () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. @@ -35,26 +41,24 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'onpath', + await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], env: { PATH: bin }, extensionToLanguage: { '.ts': 'typescript' }, - })).resolves.toBeDefined() + }))).resolves.toBeDefined() await ctx.fiber.dispose() }) it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'nope', + await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], env: { PATH: `::${join(root, 'empty')}` }, extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/was not found on PATH/) + }))).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) @@ -64,12 +68,11 @@ describe('lsp-local provider resolution', () => { await ctx.plugin(Lsp) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp - const fiber = await ctx.plugin(LspLocal, { - providerId: 'disp', + const fiber = await ctx.plugin(LspLocal, config('disp', { command: process.execPath, args: ['-e', 'setInterval(()=>{},1000)'], extensionToLanguage: { '.ts': 'typescript' }, - }) + })) await fiber.dispose() // After disposal the provider unregistered from the seam, so selection fails as unavailable. await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) @@ -79,13 +82,12 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'bad-budget', + await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], extensionToLanguage: { '.ts': 'typescript' }, killGraceMs: 0, - })).rejects.toThrow(/killGraceMs must be a positive integer/) + }))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/) await ctx.fiber.dispose() }) @@ -94,24 +96,65 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-bad', + await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) await ctx.fiber.dispose() }) it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-directory', + await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server table at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server id at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('', { + command: process.execPath, + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/server ids must be non-empty strings/) + await ctx.fiber.dispose() + }) + + it('resolves every executable before publishing any provider', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } }, + }, + })).rejects.toThrow(/was not found on PATH/) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) + + it('rolls back earlier registrations when a later server conflicts', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + }, + })).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await ctx.fiber.dispose() }) }) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index ca4df620d3..300ce7d318 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -53,10 +53,13 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'typescript', - command: serverBin, - args: ['--stdio'], - extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + servers: { + typescript: { + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }, + }, }) }, 60_000) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 8e51d0653a..8923523e05 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -7,7 +7,7 @@ This package is the interface third of the LSP capability: | Package | Role | |---|---| | `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | -| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider | +| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | | `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index f2e8d1c46a..74d9c6ae77 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -52,12 +52,15 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'inline', - command: process.execPath, - args: ['-e', serverScript(hang)], - extensionToLanguage: { '.ts': 'typescript' }, - shutdownTimeoutMs: 200, - killGraceMs: 200, + servers: { + inline: { + command: process.execPath, + args: ['-e', serverScript(hang)], + extensionToLanguage: { '.ts': 'typescript' }, + shutdownTimeoutMs: 200, + killGraceMs: 200, + }, + }, }) await ctx.plugin(TimeoutPolicy) await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {}) From e92ec69f32058ff10bede65f43e6452575226ce3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 17:55:07 +0800 Subject: [PATCH 10/48] refactor(lsp): drop redundant side-effect type import The value import of LspProviderId already pulls in the cordis module augmentation for ctx.lsp, so the separate `import type {}` is dead. --- docs/config-catalog.md | 2 +- packages/lsp/lsp-local/src/index.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 636713146c..ba2476b1a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -457,7 +457,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:83`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 07e1cbed5a..03ae5e9781 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -21,8 +21,6 @@ import type { LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' -// Side-effect type import: declaration-merges `ctx.lsp` onto Context. -import type {} from '@deepseek-ai/dsh-lsp' import { canonicalizeWorkspace, readHostSource } from './host.ts' import { abortError, LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' From a5c77325921120e326029d7f11446578b66687a8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 17 Jul 2026 16:32:34 +0800 Subject: [PATCH 11/48] fix(lsp): address lifecycle review feedback --- examples/acp-agent/tests/acp.snapshot.ts | 2 + .../acp-agent/tests/lsp.cordis.snapshot.yml | 27 ++ examples/acp-agent/tests/lsp.cordis.yml | 23 ++ .../tests/snapshots/lsp-definition/input.json | 7 + .../snapshots/lsp-definition/session.jsonl | 23 ++ .../lsp-definition/stdout.golden.jsonl | 6 + .../lsp-definition/system-prompt.golden.md | 15 + .../lsp-definition/tool-schemas.golden.json | 282 ++++++++++++++++++ .../lsp-definition/workspace/lsp-server.mjs | 57 ++++ .../lsp-definition/workspace/subject.ts | 2 + knip.json | 1 + packages/lsp/lsp-local/src/connection.ts | 33 ++ packages/lsp/lsp-local/src/index.ts | 80 ++--- packages/lsp/lsp-local/src/instance.ts | 77 ++--- packages/lsp/lsp-local/tests/instance.spec.ts | 31 ++ .../lsp/tool-lsp/tests/integration.spec.ts | 8 +- packages/lsp/tool-lsp/tests/load-path.spec.ts | 8 +- 17 files changed, 585 insertions(+), 97 deletions(-) create mode 100644 examples/acp-agent/tests/lsp.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/lsp.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/input.json create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0d29f7b5ce..15670aa995 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -28,6 +28,7 @@ const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) +const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -55,6 +56,7 @@ const SCENARIOS: Scenario[] = [ { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'workspace-edit', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, { name: 'fs-read', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-write', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-edit', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml new file mode 100644 index 0000000000..4d24ead86d --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -0,0 +1,27 @@ +# Keyless replay keeps the LSP composition intact and replaces only the model adapter. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml new file mode 100644 index 0000000000..f1eefa99af --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -0,0 +1,23 @@ +# Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path. +# The scenario workspace supplies the deterministic stdio server used by this test composition. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/input.json b/examples/acp-agent/tests/snapshots/lsp-definition/input.json new file mode 100644 index 0000000000..2b49f7d280 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl new file mode 100644 index 0000000000..e1a93752bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl new file mode 100644 index 0000000000..0abf55a261 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP definition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md new file mode 100644 index 0000000000..21a80508f7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md @@ -0,0 +1,15 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json new file mode 100644 index 0000000000..72aaa3bd7d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json @@ -0,0 +1,282 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "lsp", + "description": "Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.", + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "definition, references, implementation, or hover.", + "enum": [ + "definition", + "references", + "implementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + } + }, + "required": [ + "script", + "meta" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs new file mode 100644 index 0000000000..431b322e0b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +let buffered = Buffer.alloc(0) + +function frame(message) { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message })) + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) +} + +function location(line) { + return { + uri: pathToFileURL(resolve('subject.ts')).href, + range: { start: { line, character: 6 }, end: { line, character: 12 } }, + } +} + +function handle(message) { + switch (message.method) { + case 'initialize': + process.stdout.write(frame({ + id: message.id, + result: { + capabilities: { + positionEncoding: 'utf-16', + textDocumentSync: 1, + definitionProvider: true, + }, + }, + })) + break + case 'textDocument/definition': + process.stdout.write(frame({ id: message.id, result: [location(0), location(1)] })) + break + case 'shutdown': + process.stdout.write(frame({ id: message.id, result: null })) + break + case 'exit': + process.exit(0) + } +} + +process.stdin.on('data', (chunk) => { + buffered = Buffer.concat([buffered, chunk]) + for (;;) { + const headerEnd = buffered.indexOf('\r\n\r\n') + if (headerEnd < 0) return + const match = /Content-Length: (\d+)/i.exec(buffered.toString('ascii', 0, headerEnd)) + if (match === null) throw new Error('missing Content-Length') + const length = Number(match[1]) + const bodyStart = headerEnd + 4 + if (buffered.length < bodyStart + length) return + const message = JSON.parse(buffered.toString('utf8', bodyStart, bodyStart + length)) + buffered = buffered.subarray(bodyStart + length) + handle(message) + } +}) diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts new file mode 100644 index 0000000000..6f3d62ca43 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts @@ -0,0 +1,2 @@ +export const answer = 42 +console.log(answer) diff --git a/knip.json b/knip.json index 76634b0b78..210aea5cae 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], + "ignore": ["examples/*/tests/snapshots/*/workspace/**/*"], "ignoreBinaries": ["bwrap", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 795fe0dbff..e655877eb5 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -10,6 +10,7 @@ import type { ChildProcessByStdio } from 'node:child_process' import { spawn } from 'node:child_process' import type { Readable, Writable } from 'node:stream' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { encodeMessage, MessageDecoder } from './framing.ts' /** How to launch the server and answer its config requests. */ @@ -163,6 +164,19 @@ export class LspConnection { this.signalGroup('SIGKILL') } + /** + * Wait until the owned process group has no members. + * @param signal - optional bound for the wait. + * @returns `true` when the group exited, or `false` when the signal aborted first. + */ + async waitForProcessGroupExit(signal?: AbortSignal): Promise { + while (this.processGroupAlive()) { + if (signal?.aborted) return false + await yieldToEventLoop() + } + return true + } + /** * Signal the whole process group (negative pid) so helper processes are reached; fall back to the * direct child if the group send fails. Never throws — teardown races process exit. @@ -182,6 +196,25 @@ export class LspConnection { } } + /** Whether the detached process group still has at least one member. */ + private processGroupAlive(): boolean { + const pid = this.child.pid + /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ + if (pid === undefined) return false + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return this.child.exitCode === null && this.child.signalCode === null + /* v8 ignore stop */ + } + } + private onStdout(chunk: Buffer): void { let messages: unknown[] try { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 03ae5e9781..67e86da11c 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -169,8 +169,8 @@ function assertPositiveInteger(providerId: string, name: string, value: number): class LocalLspProvider implements LspProvider { readonly id: LspProviderId readonly extensionToLanguage: Readonly> - /** Single-flight map: canonical workspace realpath → the (pending) instance for it. */ - private readonly instances = new Map>() + /** One live instance per canonical workspace realpath. */ + private readonly instances = new Map() private disposed = false constructor( @@ -188,12 +188,17 @@ class LocalLspProvider implements LspProvider { return this.disposed } - async query(request: LspProviderQuery, signal?: AbortSignal): Promise { - /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ + /** Reject work that cannot publish or use a provider-owned instance. */ + private assertActive(signal?: AbortSignal): void { + /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls + exercise the post-await check instead. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - // Honor an already-aborted signal before any host I/O so a canceled request neither reads nor - // spawns a server. if (signal?.aborted) throw abortError(signal) + } + + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { + // Honor an already-aborted signal before host I/O so a canceled request never starts a server. + this.assertActive(signal) const workspace = await canonicalizeWorkspace(request.workspaceRoot) // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized // source must fail without leaving an idle process pooled (the pre-start rejection contract), and @@ -201,49 +206,37 @@ class LocalLspProvider implements LspProvider { const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. - /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ - if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - // Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn - // (or pool) a server solely for an operation the caller already gave up on. - if (signal?.aborted) throw abortError(signal) - let instance = await this.instanceFor(workspace) - // A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh - // one before dispatch, so this query does not have to fail on a closed connection first. One retry - // suffices — the replacement was just constructed and has not been used. + this.assertActive(signal) + let instance = this.instanceFor(workspace) + // Eviction and replacement are synchronous so disposal cannot snapshot the pool between them and + // miss a newly spawned process. if (instance.dead) { - await this.evictIfCurrent(workspace, instance) - instance = await this.instanceFor(workspace) + this.evictIfCurrent(workspace, instance) + instance = this.instanceFor(workspace) } try { return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) await this.evictIfCurrent(workspace, instance) + if (instance.dead) this.evictIfCurrent(workspace, instance) } } - /** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */ - private instanceFor(workspace: string): Promise { + /** Return or synchronously publish the one instance for a canonical workspace. */ + private instanceFor(workspace: string): LspInstance { + this.assertActive() const existing = this.instances.get(workspace) if (existing !== undefined) return existing - const created = Promise.resolve().then(() => this.createInstance(workspace)) + const created = this.createInstance(workspace) this.instances.set(workspace, created) - /* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures - surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */ - created.catch(() => { - if (this.instances.get(workspace) === created) this.instances.delete(workspace) - }) return created } - /** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */ - private async evictIfCurrent(workspace: string, instance: LspInstance): Promise { - const slot = this.instances.get(workspace) - /* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */ - if (slot !== undefined && (await settledInstance(slot)) === instance) { - this.instances.delete(workspace) - } + /** Drop the slot iff it still contains this instance. */ + private evictIfCurrent(workspace: string, instance: LspInstance): void { + /* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */ + if (this.instances.get(workspace) === instance) this.instances.delete(workspace) } private createInstance(workspace: string): LspInstance { @@ -265,26 +258,9 @@ class LocalLspProvider implements LspProvider { /** Dispose every live instance and block further queries. */ async disposeAll(): Promise { this.disposed = true - const pending = [...this.instances.values()] + const live = [...this.instances.values()] this.instances.clear() - await Promise.all(pending.map(async (entry) => { - try { - const instance = await entry - await instance.dispose() - } catch { - // A never-initialized instance already rejected; nothing to tear down. - } - })) - } -} - -/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */ -async function settledInstance(slot: Promise): Promise { - try { - return await slot - } catch { - /* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */ - return undefined + await Promise.all(live.map(instance => instance.dispose())) } } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 8bf2e48c5b..fb67f6e777 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -48,6 +48,8 @@ export class LspInstance { /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ private queue: Promise = Promise.resolve() private disposed = false + /** The one teardown transaction shared by abort, failure, and explicit disposal. */ + private teardownPromise: Promise | undefined /** Set once the process closes, so the pool can synchronously skip a dead instance. */ private processClosed = false /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ @@ -116,9 +118,8 @@ export class LspInstance { await this.abortable(this.ready, signal) } catch (error) { if (!this.dead) { - this.disposed = true /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ - await this.tearDown(error instanceof Error ? error : new Error(String(error))) + await this.startTeardown(error instanceof Error ? error : new Error(String(error))) } throw error } @@ -155,8 +156,7 @@ export class LspInstance { listener, so a synchronous didClose write failure is a defensive path. */ // A close-write failure does not replace the settled result/error, but the instance can no // longer be trusted: invalidate it and await bounded process termination. - this.disposed = true - void this.tearDown(error instanceof Error ? error : new Error(String(error))) + void this.startTeardown(error instanceof Error ? error : new Error(String(error))) /* v8 ignore stop */ } } @@ -210,19 +210,20 @@ export class LspInstance { this.connection.cancel(requestId) // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still // running: terminate the instance (disposal awaits process close) so nothing outlives the query. - using grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') - // `settled` is true if the request finished (either outcome) before the grace elapsed. - const settled = await Promise.race([ - send.then(markSettled, markSettled), - new Promise((resolve) => { - /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ - if (grace.signal.aborted) { resolve(false); return } - grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), - ]) - if (!settled && !this.disposed) { - this.disposed = true - await this.tearDown(abortError(signal)) + const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') + try { + // `settled` is true if the request finished (either outcome) before the grace elapsed. + const settled = await Promise.race([ + send.then(markSettled, markSettled), + new Promise((resolve) => { + /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (grace.signal.aborted) { resolve(false); return } + grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!settled) await this.startTeardown(abortError(signal)) + } finally { + grace[Symbol.dispose]() } throw error } @@ -262,21 +263,24 @@ export class LspInstance { * process close so nothing outlives disposal. */ async dispose(): Promise { - if (this.disposed) { - await this.connection.closed - return - } + await this.startTeardown(new Error('LSP instance disposed')) + } + + /** Publish disposal once and make every caller await the same quiescence boundary. */ + private startTeardown(reason: Error): Promise { this.disposed = true - await this.tearDown(new Error('LSP instance disposed')) + this.teardownPromise ??= this.tearDown(reason) + return this.teardownPromise } private async tearDown(_reason: Error): Promise { + const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { - using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') await this.gracefulShutdown(shutdownDeadline.signal) - return } catch { - // Graceful shutdown failed or timed out: fall through to signal escalation. + // Graceful shutdown failed or timed out; process-group cleanup below remains authoritative. + } finally { + shutdownDeadline[Symbol.dispose]() } await this.forceTerminate() } @@ -288,20 +292,21 @@ export class LspInstance { await this.abortable(this.connection.closed, signal) } - /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ + /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ private async forceTerminate(): Promise { this.connection.terminate() - using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - const closedInTime = await Promise.race([ - this.connection.closed.then(() => true), - new Promise((resolve) => { - /* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */ - if (graceDeadline.signal.aborted) { resolve(false); return } - graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), + const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') + let groupExited: boolean + try { + groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) + } finally { + graceDeadline[Symbol.dispose]() + } + if (!groupExited) this.connection.kill() + await Promise.all([ + this.connection.closed, + this.connection.waitForProcessGroupExit(), ]) - if (!closedInTime) this.connection.kill() - await this.connection.closed } } diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 52fc751754..f46d72571b 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -236,6 +236,26 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) + it('awaits a surviving process-group helper on every concurrent dispose', async () => { + const marker = join(root, 'helper.pid') + const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' + const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' + + `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});` + + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));` + + RESPONDING_SERVER + const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await run(instance, 'definition') + const helperPid = Number(await readFile(marker, 'utf8')) + try { + const first = instance.dispose() + await instance.dispose() + expect(processAlive(helperPid)).toBe(false) + await first + } finally { + if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') + } + }) + it('carries a non-Error abort reason as a generic aborted error', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() @@ -245,3 +265,14 @@ describe('LspInstance disposal', () => { await expect(pending).rejects.toThrow(/aborted/) }) }) + +/** Probe a pid without changing its state. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 74d9c6ae77..5c98d9fff0 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -12,10 +12,8 @@ import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' /** - * Real-composition integration: the model-facing `lsp` tool over the real seam, the real - * `dsh-lsp-local` provider (driving an inline stdio server), and the real `dsh-timeout-policy`, all - * driven only through `ctx.tools.execute()`. Pins that a query round-trips end to end and that the - * policy's `TOOL_TIMEOUT` budget wins when the server hangs. + * Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy. + * The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path. */ let root: string @@ -77,7 +75,7 @@ function call(ctx: Context, args: unknown) { }) } -describe('tool-lsp real composition', () => { +describe('tool-lsp integration', () => { it('round-trips a definition query through the real provider and renders a location', async () => { const ctx = await mount(false) const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 }) diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts index 13dbaa7b78..7b3ec0f241 100644 --- a/packages/lsp/tool-lsp/tests/load-path.spec.ts +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -1,15 +1,15 @@ /** - * Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a + * Loader export-shape guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a * stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the - * bare `apply`, dropping `inject` (postmortem 0001). This unwraps through the REAL - * `Loader.prototype.unwrapExports` and verifies the namespace shape survives. + * bare `apply`, dropping `inject` (postmortem 0001). This verifies the namespace survives + * `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition. */ import { describe, expect, it } from 'vitest' import Loader from '@cordisjs/plugin-loader' import * as toolLsp from '@deepseek-ai/dsh-tool-lsp' -describe('dsh-tool-lsp real-load-path guard', () => { +describe('dsh-tool-lsp Loader export-shape guard', () => { it('has no default export and keeps name/inject/Config through unwrapExports', () => { expect('default' in toolLsp).toBe(false) From 9d310ecc27954f412e709fe8a4dd891406ff2e1d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:19:57 +0800 Subject: [PATCH 12/48] feat(invariants): add package-owned service seam --- docs/architecture.md | 7 +- docs/capability-seams.md | 14 +- docs/config-catalog.md | 26 +- docs/cordis-catalog/services.md | 18 + docs/core-data-structures/session.md | 4 +- docs/event-producer-consumer.md | 10 +- docs/module-graph.md | 21 +- docs/rfc/INDEX.md | 1 + ...06-11-dev-invariants-over-deep-readonly.md | 10 +- .../2026-06-11-structured-error-taxonomy.md | 4 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-06-18-session-surface.md | 3 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...-package-owned-invariant-service.i18n.yaml | 6 + ...6-07-19-package-owned-invariant-service.md | 97 ++ ...7-19-package-owned-invariant-service.zh.md | 97 ++ .../2026-06-18-compaction-capability-seam.md | 4 +- .../feature/2026-07-07-session-prefix.md | 2 +- ...pt-program-backed-semantic-gates.i18n.yaml | 4 +- ...ypescript-program-backed-semantic-gates.md | 8 +- ...script-program-backed-semantic-gates.zh.md | 8 +- .../2026-06-16-typed-event-schemas.md | 4 +- .../tests/compact-loop-repro.spec.ts | 16 +- .../cordis/tool-cordis/src/api-catalog.ts | 18 + packages/core/agent-loop/README.md | 4 + packages/core/agent-loop/package.json | 6 + packages/core/agent-loop/src/invariant.ts | 73 ++ .../tests/contract-regressions.spec.ts | 30 +- .../core/agent-loop/tests/invariant.spec.ts | 103 +++ .../core/agent-loop/tests/turn-stop.spec.ts | 14 +- packages/core/agent-loop/tsconfig.json | 3 + packages/core/agent-loop/tsdown.config.ts | 25 + packages/core/agent/README.md | 2 + packages/core/agent/package.json | 7 + packages/core/agent/src/invariant.ts | 35 + packages/core/agent/tests/invariant.spec.ts | 58 ++ packages/core/agent/tsconfig.json | 3 + packages/core/agent/tsdown.config.ts | 25 + packages/core/scope/README.md | 2 + packages/core/scope/package.json | 7 + packages/core/scope/src/invariant.ts | 41 + .../core/scope/src/scoped-events.generated.ts | 50 + packages/core/scope/tests/invariant.spec.ts | 81 ++ packages/core/scope/tsconfig.json | 3 + packages/core/scope/tsdown.config.ts | 27 + packages/core/session/README.md | 4 +- packages/core/session/package.json | 7 + packages/core/session/src/invariant.ts | 230 +++++ packages/core/session/tests/invariant.spec.ts | 268 ++++++ packages/core/session/tsconfig.json | 3 + packages/core/session/tsdown.config.ts | 25 + packages/examples/agent-spine-demo/README.md | 15 +- .../examples/agent-spine-demo/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 21 +- .../agent-spine-demo/tests/agent-core.spec.ts | 41 + .../stdio-demo/tests/built-bin.e2e.ts | 2 +- packages/llm/llm/README.md | 2 +- .../helper/src/features/builtin/helpers.ts | 15 +- .../sdk/helper/src/features/builtin/spine.ts | 6 +- packages/sdk/helper/tests/project.spec.ts | 11 + .../tests/multi-subagent.spec.ts | 14 +- .../subagent-fork/tests/subagent-fork.spec.ts | 16 +- .../tests/structured.spec.ts | 14 +- .../tests/subagent-inprocess.spec.ts | 14 +- .../tests/subagent-spawn.spec.ts | 18 +- packages/support/invariants/README.md | 83 +- packages/support/invariants/package.json | 17 +- packages/support/invariants/src/index.ts | 544 ++++------- .../invariants/src/scoped-events.generated.ts | 71 -- .../invariants/tests/invariants.spec.ts | 851 ------------------ .../support/invariants/tests/service.spec.ts | 266 ++++++ packages/support/invariants/tsconfig.json | 23 +- packages/ui/acp/tests/config-options.spec.ts | 14 +- .../tests/integration.spec.ts | 14 +- pnpm-lock.yaml | 40 +- scripts/check-workspace-constraints.ts | 21 + scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-scoped-events.ts | 68 +- tsconfig.base.json | 5 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/invariants.md | 32 + 83 files changed, 2225 insertions(+), 1557 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md create mode 100644 packages/core/agent-loop/src/invariant.ts create mode 100644 packages/core/agent-loop/tests/invariant.spec.ts create mode 100644 packages/core/agent-loop/tsdown.config.ts create mode 100644 packages/core/agent/src/invariant.ts create mode 100644 packages/core/agent/tests/invariant.spec.ts create mode 100644 packages/core/agent/tsdown.config.ts create mode 100644 packages/core/scope/src/invariant.ts create mode 100644 packages/core/scope/src/scoped-events.generated.ts create mode 100644 packages/core/scope/tests/invariant.spec.ts create mode 100644 packages/core/scope/tsdown.config.ts create mode 100644 packages/core/session/src/invariant.ts create mode 100644 packages/core/session/tests/invariant.spec.ts create mode 100644 packages/core/session/tsdown.config.ts delete mode 100644 packages/support/invariants/src/scoped-events.generated.ts delete mode 100644 packages/support/invariants/tests/invariants.spec.ts create mode 100644 packages/support/invariants/tests/service.spec.ts create mode 100644 website/zh-CN/api/harness/invariants.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..1035407375 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,10 +1,10 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. +The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop. ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations. +A harness is a [Cordis](cordis-primer.md) context with services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and prompt/tool/provider/adapter/listener registrations. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -37,6 +37,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces | +| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | registry and package-name selection for package-owned runtime checks | ## Event @@ -134,7 +135,7 @@ Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, re The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` plus the header's prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 58641456b1..883e747723 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -24,6 +24,8 @@ flowchart LR pkg_session_query["session-query"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + svc_invariants["ctx.invariants
Package-owned invariant registry"] + pkg_scope["scope"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -109,6 +111,7 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_invariants --> svc_invariants pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -147,7 +150,6 @@ flowchart LR svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo - svc_agents --> pkg_invariants svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash @@ -158,6 +160,10 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs + svc_invariants --> pkg_agent + svc_invariants --> pkg_agent_loop + svc_invariants --> pkg_scope + svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic svc_permission --> pkg_acp @@ -171,7 +177,6 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo - svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_subagent_inprocess @@ -208,14 +213,15 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225098e851..76663117b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -115,7 +115,8 @@ Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -142,6 +143,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task control-tool wait bounds. */ toolTasks?: toolTasks.Config + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -155,9 +158,9 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:62`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -379,6 +382,22 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-invariants` + +```ts config-catalog +/** Runtime invariant selection configured on the service plugin. */ +export interface Config { + /** Global switch; defaults to `true`. */ + readonly enabled?: boolean + /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */ + readonly package_allowlist?: string[] + /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */ + readonly package_blocklist?: string[] +} +``` + +Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) + ## `@deepseek-ai/dsh-jsonrpc` Requires: `agents` @@ -1458,7 +1477,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) -- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..5a868e1eff 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -479,6 +479,24 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](.. Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) +## `ctx.invariants` — `InvariantService` + +Package-owned invariant registry with global and regex-based selection. + +```ts cordis-catalog +/** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ +register(packageName: string, installer: InvariantInstaller): () => void +``` + +Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/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/core-data-structures/session.md b/docs/core-data-structures/session.md index 3668191fb2..2dc5a1c34e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -502,7 +502,7 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events @@ -512,6 +512,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse ## Durability contract -What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting the invariants plugin checks, is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..29f8763460 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -27,10 +27,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm-replay`](../packages/support/llm-replay) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | +| `internal/dispatch` | - | [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0886877f0e..af8504be18 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,12 +151,14 @@ flowchart TD pkg_workflow_workerthread["workflow-workerthread"] end pkg_llm --> pkg_brand + pkg_scope --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_helper --> pkg_brand pkg_scripts --> pkg_app_boot pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand + pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope pkg_system_prompt --> pkg_llm @@ -168,6 +170,7 @@ flowchart TD pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session pkg_agent --> pkg_brand + pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope pkg_agent --> pkg_session @@ -211,10 +214,6 @@ flowchart TD pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence - pkg_invariants --> pkg_agent - pkg_invariants --> pkg_llm - pkg_invariants --> pkg_scope - pkg_invariants --> pkg_session pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -247,6 +246,7 @@ flowchart TD pkg_permission --> pkg_session pkg_permission --> pkg_user_approval pkg_agent_loop --> pkg_agent + pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session @@ -391,6 +391,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_scope pkg_agent_spine_demo --> pkg_session pkg_agent_spine_demo --> pkg_skill pkg_agent_spine_demo --> pkg_skill_local @@ -451,27 +452,28 @@ flowchart TD | [`paths`](../packages/util/paths) | `util` | — | | [`retention`](../packages/util/retention) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | -| [`scope`](../packages/core/scope) | `core` | — | | [`skill`](../packages/skill/skill) | `skill` | — | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | +| [`invariants`](../packages/support/invariants) | `support` | — | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | +| [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | @@ -492,7 +494,6 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | @@ -501,7 +502,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -528,7 +529,7 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4476c47a32..c7e542276e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -167,6 +167,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | +| [Package-owned invariant service seam](implemented/architecture/2026-07-19-package-owned-invariant-service.md) | 2026-07-19 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index aac3991f46..9e272456fc 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -28,11 +28,11 @@ This guarantee belongs in `Session`, not in an optional listener, because every `deriveMessages()` projects logged surface events into detached, deep-frozen `Message` objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call. -### The invariants plugin checks relationships +### Package-owned invariant companions check relationships -`dsh-invariants` is a pure-listener development plugin. It does not freeze records and has no configuration; disposal removes only its assertions. It checks rules that require trace state or observation of another seam, including monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. +`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Optional `./invariant` companions from `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` own the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). -When the plugin attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. This makes hot reload safe in the middle of a turn without giving the plugin ownership of session storage. +When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage. ## Alternatives considered @@ -53,6 +53,6 @@ Detaching `deriveMessages()` would protect the most common request path but leav - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. - `session.events` exposes stable immutable snapshots instead of the private growing array. - Request-side mutation cannot reach stored history through derived messages. -- Development builds can enable relational assertions without changing storage behavior, and disposing or omitting the plugin does not weaken log immutability. -- `dsh-invariants` has no `Config` surface because it has no behavior to tune. +- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. +- `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. - The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records. diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 01e50da2ff..f1114c62de 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -10,7 +10,7 @@ Failures crossed seams as bare strings. A tool error flattened to a text block A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams. -- `LlmError`, `ToolArgsError` (dsh-tools), and `InvariantError` (dsh-invariants) now extend it, keeping their existing codes. +- `LlmError` and `ToolArgsError` (dsh-tools) extend it, keeping their existing codes. - `ToolExecutionResult` gains optional `error: { name, code }`, populated in the registry's catch when the thrown value is a `HarnessError`. The agent loop forwards it onto the `tool/result` session event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged. - The loop's `toError` wraps a non-Error throw in a `HarnessError` (`code: 'UNKNOWN'`, original chained as `cause`) instead of a bare `Error`, so even a bad throw carries a routable code into the session `error` event (which already surfaced `code`). @@ -19,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every - Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge. - `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay. -- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text. +- Argument validation retains its existing code and behavior; package-owned diagnostic invariants carry their stable code independently so the invariant registry does not import a product package. The shared base adds cross-seam routing metadata without changing model-facing text. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index a54f735219..81b1bd25df 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -21,7 +21,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di - An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. -- The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. +- The optional `dsh-session/invariant` companion registers the dev check with `ctx.invariants`: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching. diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index d639fe2917..55ba8cce4a 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -47,7 +47,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls ### Invariants -The dev-mode invariants plugin validates: `sourceEventSeqs` references (only `assistant/message` may use an empty list; otherwise no duplicates, references earlier events, and references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). +`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty provenance list; references are unique, earlier, and known; replacement endpoints exist in surface order; and provenance covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions. Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy. @@ -63,7 +63,6 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). -- **`packages/support/invariants`**: Surface-related validation rules. - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`packages/session-persistence/session-persistence`**: Abstract interface unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 9b53369086..e86d6b9610 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** In development, the optional `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 2c3126793f..e7e43cc1eb 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -322,7 +322,7 @@ TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messa ### Runtime invariants cover cross-service facts -The invariants plugin verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. Session trace validation stages before append commit and advances after the same event commits. +The optional `dsh-scope/invariant` companion verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml new file mode 100644 index 0000000000..ce8fabdc40 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -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 +2026-07-19-package-owned-invariant-service.md: f147cd91134509e17df39d1acbb28ae8b521c2f8 +2026-07-19-package-owned-invariant-service.zh.md: a106b57137c36009d0d4b4d39f109b7abf857f42 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md new file mode 100644 index 0000000000..f147cd9113 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -0,0 +1,97 @@ +# RFC: Package-owned invariant service seam + +Status: implemented + +English | [中文](2026-07-19-package-owned-invariant-service.zh.md) + +## Problem + +Runtime invariant checks span session traces, agent state, scoped dispatch, and request reconstruction. Putting all checks in one diagnostics package makes that package import product vocabularies from unrelated domains, centralizes tests away from their owners, and requires the central package to change whenever a product package adds or removes a check. + +Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. + +## Decision + +### One registry service, package-owned contributions + +`@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. + +Packages expose diagnostics through optional `./invariant` companion plugins. Their root entrypoints do not import or register diagnostics implicitly, so loading a product package does not change runtime checking or require the invariant service. + +### Configuration and selection + +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` + +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. For a full registration name, selection is: + +```ts +export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean { + return enabled + && ( + package_allowlist.length === 0 + || package_allowlist.some(pattern => pattern.test(packageName)) + ) + && !package_blocklist.some(pattern => pattern.test(packageName)) +} +``` + +Blocklist matches override allowlist matches. Each list entry is a case-sensitive JavaScript regex source compiled by `new RegExp(pattern)`. Matching is unanchored unless callers supply `^` and `$`; slash-delimited syntax and flags are not interpreted. Startup rejects blank, whitespace-padded, invalid, or duplicate sources within either list. A source that matches no loaded package remains valid because registration order, later loading, and HMR must not change config validity. + +### Registration and failure ownership + +The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. + +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. + +Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. + +The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. + +### Shipped companions + +| Companion entry | Registration name | Owned checks | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | session sequence, turn/step enclosure, and same-step call/result trace | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | + +Each owner contains its source and focused tests. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. The service package retains only registry tests. + +### Scoped-event semantic map + +The generated scoped-event subject resolver lives in `dsh-scope`, beside the contract and invariant that consume it. `gen-scoped-events` uses the root TypeScript Program to enumerate `this: Scoped` declarations, infer routing-key types from real `scopeTarget(base, key)` calls, and require one unambiguous payload subject or an explicit unsupported marker. The committed runtime map imports no event-owner package, so semantic completeness does not expand either the service or scope package's runtime closure. + +### Standard composition and SDK output + +The standard agent spine mounts the service and all four companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. + +Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. + +## Testing + +Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owner tests keep each invariant's positive and negative behavior beside its source. + +Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. + +## Alternatives considered + +- **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. +- **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. +- **Discover every `invariant.ts` file automatically.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. +- **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. + +## Consequences + +- Product packages own and test their relational assertions while the service stays product-independent. +- Standard compositions can disable all checks or select package names without changing their plugin tree. +- Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. +- One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. +- Regex sources are deployment configuration and remain fixed until the service reloads. +- Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md new file mode 100644 index 0000000000..a106b57137 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -0,0 +1,97 @@ +# RFC: 包拥有的不变式服务接缝 + +Status: implemented + +[English](2026-07-19-package-owned-invariant-service.md) | 中文 + +## 问题 + +运行时不变式检查跨越会话轨迹、agent 状态、作用域 dispatch 和请求重建。如果所有检查都放在一个诊断包中,该包就必须导入彼此无关的产品领域词汇,测试也会离开真正的所有者;任何产品包新增或移除检查时,都要修改中央包。 + +部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 + +## 决策 + +### 一个注册服务,贡献归包所有 + +`@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 + +各包通过可选的 `./invariant` 伴随插件公开诊断。根入口不会隐式导入或注册诊断,因此加载产品包本身不会改变运行时检查,也不要求不变式服务存在。 + +### 配置与选择 + +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` + +默认值为 `enabled: true`、`package_allowlist: []` 和 `package_blocklist: []`。对完整注册名的选择规则为: + +```ts +export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean { + return enabled + && ( + package_allowlist.length === 0 + || package_allowlist.some(pattern => pattern.test(packageName)) + ) + && !package_blocklist.some(pattern => pattern.test(packageName)) +} +``` + +blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写的 JavaScript 正则表达式源,通过 `new RegExp(pattern)` 编译。除非调用方提供 `^` 与 `$`,否则匹配不锚定;系统不会解析斜杠包围语法或 flags。服务启动会拒绝空白、首尾带空白、无效或同一列表内重复的源。没有匹配当前已加载包的有效源仍然合法,因为注册顺序、稍后加载和 HMR 不应改变配置有效性。 + +### 注册与失败归属 + +公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 + +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 + +注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 + +原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 + +### 已提供的伴随插件 + +| 伴随入口 | 注册名 | 所属检查 | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | 会话序号、turn/step 包围关系和同 step 的 call/result 轨迹 | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent 状态转换 | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | + +每个所有者都保存自己的源码与聚焦测试。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。服务包只保留注册服务测试。 + +### Scoped event 语义映射 + +生成的 scoped event 主体解析表位于 `dsh-scope`,与消费它的契约和不变式相邻。`gen-scoped-events` 使用根 TypeScript Program 枚举 `this: Scoped` 声明,从真实 `scopeTarget(base, key)` 调用推断路由键类型,并要求唯一、无歧义的 payload 主体或显式 unsupported 标记。提交的运行时映射不导入事件所有者包,因此语义完整性不会扩大服务包或 scope 包的运行时依赖闭包。 + +### 标准组合与 SDK 输出 + +标准 agent spine 会挂载服务和四个伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 + +Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 + +## 测试 + +服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。所有者测试把各不变式的正向与负向行为保留在其源码旁边。 + +组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 + +## 考虑过的替代方案 + +- **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 +- **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 +- **自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。 +- **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 + +## 后果 + +- 产品包拥有并测试自己的关系断言,服务保持与产品无关。 +- 标准组合无需改变插件树即可关闭全部检查或按包名选择。 +- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 +- 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 +- 正则表达式源属于部署配置,在服务重载前保持固定。 +- 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 1d767d49fc..8e20795e40 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -8,7 +8,7 @@ A long-running agent conversation grows without bound. As the event log accumula The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries 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 policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed 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. +Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed 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 and Session's always-on append/seed boundary reject `surfaceOp` on it. ## Decision @@ -114,7 +114,7 @@ Two failure paths, both documented: - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`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. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation. -- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. +- **`dsh-session`** accepts `start > end` numerically when the named entries appear in valid surface position order: a head-anchored compaction can place a high-seq replacement entry at an older range's position. The optional session companion reuses turn enclosure unchanged. - **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. ## Testing diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 0c642e7c68..8fefb6726b 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -14,7 +14,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w Three properties carry the design: -- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. +- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts) companion recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire when that contribution is enabled. - **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()` or tool/prompt-submit `additionalContexts` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. - **Exact in the durable request envelope.** Composition precedes the instance's first `agent/pre-step` and request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml index eb411f1158..72f9163933 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-typescript-program-backed-semantic-gates.md: 3e7a76e86d83080ae1a4f91ca97cc9749c90ef29 -2026-07-14-typescript-program-backed-semantic-gates.zh.md: 0a13452012e7f6cbd3ad7994845ba1985355c089 +2026-07-14-typescript-program-backed-semantic-gates.md: 427ffbf93a67a49664115f2376351c8da5afb9d1 +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 56cba0141fdba98e0186093dca565674dd47cc9f diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md index 3e7a76e86d..427ffbf93a 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md @@ -38,9 +38,9 @@ Every declared harness event must have a discovered producer. A missing producer Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require `@dshScopeScan unsupported`, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type. -The committed [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) imports every scoped-event owner for its type-side `Events` contributions. Each generated lambda accepts `Parameters`, and the complete object satisfies a `Record` over the derived `ScopedEventName` union. Ordinary TypeScript compilation therefore checks event existence, parameter position, property access, and scoped-event completeness. The only cast adapts Cordis's runtime `unknown[]` dispatch boundary to the already type-checked resolver. +The committed [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) is a runtime-only map in the package that owns scoped dispatch and imports no event-owner package. Semantic completeness lives in the generator: its root Program enumerates every scoped `Events` declaration and real `scopeTarget` contract, resolves the unique payload path with the checker, and refuses missing, stale, or ambiguous entries before rendering the `unknown[]` runtime boundary. -The invariants plugin consumes this generated runtime map instead of maintaining its own table. Additional event-owner packages are dev dependencies and project references of `dsh-invariants`, not peer dependencies, so the compile-time aggregation does not expand the plugin's runtime closure. +The `dsh-scope/invariant` companion consumes this map instead of maintaining a handwritten table. Because Program analysis happens in the repository gate rather than through generated type imports, neither `dsh-scope` nor `dsh-invariants` acquires dependencies on every event owner. ### Semantic gaps fail explicitly @@ -48,7 +48,7 @@ The generators reject missing declarations, config diagnostics, widened or gener ## Verification -`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` freshness-checks the generated resolver map. The root TypeScript build compiles the resolver against merged `Events`; workspace constraints and runtime-closure checks ensure its type-only aggregation does not become a deployment dependency. +`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` reruns the Program analysis while freshness-checking the generated resolver map. The root TypeScript build compiles its runtime adapter; workspace constraints and runtime-closure checks keep event-owner aggregation out of deployment dependencies. ## Alternatives considered @@ -58,6 +58,6 @@ The generators reject missing declarations, config diagnostics, widened or gener - Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions. - Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables. -- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation or compilation at the owning contract. +- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation at the owning contract. - Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph. - Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md index 0a13452012..56cba0141f 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md @@ -38,9 +38,9 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。 -仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。 +仓库提交的 [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 契约,通过 checker 解析唯一的 payload 路径,并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。 -不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。 +`dsh-scope/invariant` companion 消费这份映射,不再维护手写事件表。Program 分析发生在仓库门禁内,而不是依赖生成的类型导入,因此 `dsh-scope` 和 `dsh-invariants` 都不需要依赖所有事件声明方。 ### 语义缺口必须显式失败 @@ -48,7 +48,7 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 ## 验证 -`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。 +`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查;`verify-scoped-events` 会重新运行 Program 分析,并检查生成映射的新鲜度。根 TypeScript 构建编译其运行时适配器;workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。 ## 考虑过的替代方案 @@ -58,6 +58,6 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 - 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定; - 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表; -- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败; +- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成失败; - 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图; - 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 9eb4c585a4..5db00851ae 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -28,7 +28,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **Six merge-extensible maps** (~370 LOC of core types): `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap` (in `dsh-llm`); `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap` (in `dsh-session`). - **~10 `declare module` augmentation sites** across `dsh-agent`, `dsh-agent-loop`, `dsh-bash`, `dsh-llm`, `dsh-session`, `dsh-session-persistence`, `dsh-system-prompt`, `dsh-tools` — each would move from declaration merging to a runtime `register()` call. - **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary. -- **~7 switch-consumers** that branch on these unions: `deriveMessages` (`dsh-session`), `BlockAssembler` (`dsh-llm`), the `dsh-invariants` plugin, both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. +- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the optional invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. @@ -37,7 +37,7 @@ This is a repository-wide vocabulary redesign, not a persistence implementation ## Alternatives considered ### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary -Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility, enforced by TypeScript at compile time and by the `dsh-invariants` plugin's structural checks in dev. +Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships in dev but do not provide general runtime shape schemas. - **Pros**: zero churn; plugin extension is a one-line `interface` augmentation with full type inference and no runtime registration ceremony; no new runtime dependency; the `defineTool` DSL and `assertNever` exhaustiveness keep working. - **Cons**: no runtime structural validation at the persistence boundary or at plugin seams; a malformed-but-JSON datum is caught late. diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 1327f073c5..0410940127 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -8,7 +8,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -95,10 +98,17 @@ class OverflowRecoveryAdapter extends LlmAdapter { } } +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 400 }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) @@ -223,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const ctx = new Context() const adapter = new OverflowRecoveryAdapter(delivery) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 128 }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52f8477318..9cbfd2a59b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -250,6 +250,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'invariants', + summary: 'Package-owned invariant registry with global and regex-based selection.', + methods: [ + { + signature: 'register(packageName: string, installer: InvariantInstaller): () => void', + jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - synchronous listener installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', + }, + ], + }, { key: 'llm', summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', @@ -1176,6 +1186,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InjectOptions', declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', }, + { + name: 'InvariantFailure', + declaration: 'export type InvariantFailure = (message: string) => never;', + }, + { + name: 'InvariantInstaller', + declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void;\n readonly inject?: Inject;\n}', + }, { name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d604e2b16f..a015230ce0 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -27,6 +27,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo `agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services. +### Invariant companion + +The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract. + ### Configuration (schemastery) ```ts diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 7e2fb235a2..8e9a2b93bb 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -11,10 +11,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -22,6 +27,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts new file mode 100644 index 0000000000..41b9112a4e --- /dev/null +++ b/packages/core/agent-loop/src/invariant.ts @@ -0,0 +1,73 @@ +/** + * Package-owned request-reconstruction invariant for loop-built LLM calls. + * @module @deepseek-ai/dsh-agent-loop/invariant + */ + +import type { Context } from 'cordis' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' + +/** Cordis companion plugin name. */ +export const name = 'agent-loop-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants', 'sessions'] + +/** Install the request-reconstruction contribution into its child registration fiber. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + // Prepend prevents a short-circuiting replay listener from silencing the + // check; correctness itself comes from the sequence-bounded reconstruction. + ctx.on('llm/stream', (options: GenerateOptions, next) => { + if (options.sessionId === undefined || !Object.isFrozen(options)) return next() + const session = ctx.sessions.get(options.sessionId) + if (!session) return next() + if (!Object.isFrozen(options.messages)) { + fail('a loop-built request must carry a frozen messages array') + } + + const events = session.events + let boundary = -1 + for (let index = events.length - 1; index >= 0; index -= 1) { + if (events[index]?.type === 'step/start') { + boundary = index + break + } + } + if (boundary === -1) { + return fail('a loop-built request with no step/start in its session log') + } + const header = foldRequestHeader(events) + if (header === undefined) { + return fail('a loop-built request with no request/header event in its session log') + } + const rebuilt = new Session( + SessionId(`${String(session.id)}-invariant-rebuild`), + structuredClone(events.slice(0, boundary)), + ) + const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] + if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { + fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) + } + + const headerMatches = options.model === header.config.model + && options.system === header.system + && options.temperature === header.config.temperature + && options.maxTokens === header.config.maxTokens + && JSON.stringify(options.stop) === JSON.stringify(header.config.stop) + && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? []) + if (!headerMatches) { + fail(`llm request for session "${String(session.id)}" diverges from the folded request header`) + } + return next() + }, { global: true, prepend: true }) +}, { inject: ['sessions'] }) + +/** + * Register the agent-loop invariant companion. + * @param ctx - Cordis context carrying the invariant and session services. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 80d085c32b..0146efbd1b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -7,9 +7,19 @@ import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/ds import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function driverDone(agent: Agent): Promise { return (agent as Agent & { done: Promise }).done } @@ -144,7 +154,7 @@ describe('successful provider completion survives agent/step-result failure', () ): Promise { const adapter = new MockAdapter([response]) const ctx = await harness(adapter) - await ctx.plugin(Invariants) + await mountInvariants(ctx) const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' }) const failure = new Error(`${id} result processing failed`) const reported: Error[] = [] @@ -1014,7 +1024,7 @@ describe('step boundary publication order', () => { }) describe('turn and step boundary recovery', () => { - // The invariants plugin makes an unbalanced log fail the test. + // The session invariant companion makes an unbalanced log fail the test. async function balancedHarness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -1023,7 +1033,7 @@ describe('turn and step boundary recovery', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -1437,7 +1447,7 @@ describe('surface: assistant/message records exact empty provenance when no chun // stream from legacy events whose provenance was not recorded. const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) - await ctx.plugin(Invariants) + await mountInvariants(ctx) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ @@ -1474,7 +1484,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) // Parent-owned listener survives agent-fiber disposal. @@ -1525,7 +1535,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { @@ -1580,7 +1590,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1631,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1680,7 +1690,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts new file mode 100644 index 0000000000..a61cda6f99 --- /dev/null +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentLoopInvariant) + return ctx +} + +function dispatch(ctx: Context, options: unknown): void { + void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) +} + +async function requestSetup() { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('req-check')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const boundary = session.deriveMessages() + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) + return { ctx, session, boundary } +} + +describe('request-reconstruction invariant', () => { + it('accepts a frozen request equal to the boundary derivation and folded header', async () => { + const { ctx, session, boundary } = await requestSetup() + const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + expect(() => { dispatch(ctx, options) }).not.toThrow() + }) + + it('uses the step boundary rather than content appended afterward', async () => { + const { ctx, session, boundary } = await requestSetup() + session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) + const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + expect(() => { dispatch(ctx, options) }).not.toThrow() + }) + + it('requires the folded session prefix ahead of derived history', async () => { + const { ctx, session, boundary } = await requestSetup() + const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) + .not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + }) + + it('rejects message and header divergence', async () => { + const { ctx, session, boundary } = await requestSetup() + const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) + .toThrow(/diverges from the folded request header/) + }) + + it('rejects loop requests with no boundary or header', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('req-bare')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) + expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) + session.append('step/start', { turn: 1, step: 1 }) + expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) + }) + + it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => { + const { ctx, session, boundary } = await requestSetup() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) }) + .toThrow(/frozen messages array/) + expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }) + .not.toThrow() + }) + + it('prepends ahead of a short-circuiting stream listener', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.on('llm/stream', () => (async function* () {})() as never) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentLoopInvariant) + const session = ctx.sessions.create(SessionId('prepend-check')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) + const divergent = Object.freeze({ + model: 'm', + messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), + sessionId: session.id, + }) + expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/) + }) +}) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 355e1e8e3d..736ada7013 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -7,9 +7,19 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmService) @@ -17,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 5d7cf98bb7..0949f18453 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent-loop/tsdown.config.ts b/packages/core/agent-loop/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/agent-loop/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bf5e563443..574d865792 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -2,6 +2,8 @@ Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable. +The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly. + ## Service: `AgentRegistry` (ctx key: `agents`) Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package. diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index bcf75ea7ca..f40a5da441 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -31,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts new file mode 100644 index 0000000000..1902f3e746 --- /dev/null +++ b/packages/core/agent/src/invariant.ts @@ -0,0 +1,35 @@ +/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent' + +/** Cordis companion plugin name. */ +export const name = 'agent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Install the agent contribution into its child registration fiber. */ +const install: InvariantInstaller = (ctx, fail) => { + const lastStatus = new WeakMap() + ctx.on('agent/status', (agent, status) => { + const previous = lastStatus.get(agent) + if (previous === status) { + fail(`agent/status repeated ${status} (no-op transition)`) + } + if (previous === 'disposed') { + fail(`agent/status left terminal state disposed → ${status}`) + } + lastStatus.set(agent, status) + }, { global: true }) +} + +/** + * Register the agent 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)) diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts new file mode 100644 index 0000000000..3c0d147b9a --- /dev/null +++ b/packages/core/agent/tests/invariant.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(AgentInvariant) + return ctx +} + +function mockAgent(id: string): Agent { + return { id } as unknown as Agent +} + +describe('agent status invariants', () => { + it('accepts lifecycle transitions through idle, running, and disposed', async () => { + const ctx = await setup() + const agent = mockAgent('a1') + expect(() => { + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') + }).not.toThrow() + + const running = mockAgent('a2') + ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running') + expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow() + }) + + it('rejects a no-op transition', async () => { + const ctx = await setup() + const agent = mockAgent('a3') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }) + .toThrow(/no-op transition/) + }) + + it('rejects leaving the terminal disposed state', async () => { + const ctx = await setup() + const agent = mockAgent('a4') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }) + .toThrow(/left terminal state disposed/) + }) + + it('tracks agents independently', async () => { + const ctx = await setup() + const a = mockAgent('a5') + const b = mockAgent('b5') + ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() + }) +}) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 2692e1b7f7..b6d6c9e6bf 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent/tsdown.config.ts b/packages/core/agent/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/agent/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 36cc059bc4..3ee979bda0 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -13,6 +13,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c - `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. +The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls. + ## Design contract The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 88d78ceb8b..4679e2755a 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/scope/src/invariant.ts b/packages/core/scope/src/invariant.ts new file mode 100644 index 0000000000..a5bd59f263 --- /dev/null +++ b/packages/core/scope/src/invariant.ts @@ -0,0 +1,41 @@ +/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' +import { scopedSubjectResolverFor } from './scoped-events.generated.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-scope' + +/** Cordis companion plugin name. */ +export const name = 'scope-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Install the scoped-dispatch contribution into its child registration fiber. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/dispatch', (_mode, eventName, args, thisArg) => { + const subjectOf = scopedSubjectResolverFor(eventName) + if (subjectOf === undefined) return + if (!isScopeCarrier(thisArg)) { + fail( + `"${eventName}" is a scope-filtered event but was dispatched without a scope carrier — ` + + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))', + ) + } + if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) { + fail( + `"${eventName}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` + + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))', + ) + } + }, { global: true }) +} + +/** + * Register the scope 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)) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts new file mode 100644 index 0000000000..4e23706fb3 --- /dev/null +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -0,0 +1,50 @@ +/** + * Generated scoped-event routing-subject resolvers for dsh-scope invariants. + * Do not edit by hand; run `pnpm run gen-scoped-events`. + * + * @module @deepseek-ai/dsh-scope/scoped-events.generated + */ + +type ScopedSubjectResolver = (args: readonly unknown[]) => unknown + +const scopedSubjectResolvers: Readonly> = Object.freeze({ + 'agent/created': args => args[0], + 'agent/disposed': args => args[0], + 'agent/error': args => args[0], + 'agent/post-step': args => args[0], + 'agent/pre-step': args => args[0], + 'agent/prompt-submit': args => args[0], + 'agent/queued': args => args[0], + 'agent/request': args => args[0], + 'agent/request-error': args => args[0], + 'agent/session-prefix': args => args[0], + 'agent/session-start': args => args[0], + 'agent/status': args => args[0], + 'agent/step-result': args => args[0], + 'agent/turn-continuation': args => args[0], + 'agent/turn-stop': args => args[0], + 'approval/request': args => (args[0] as Record)['agent'], + 'session/created': null, + 'session/disposed': null, + 'session/event': null, + 'session/flush': null, + 'subagent/end': null, + 'subagent/start': null, + 'system-prompt/assemble': args => (args[1] as Record)['scope'], + 'tools/execute': args => (args[0] as Record)['agent'], + 'tools/post-execute': args => (args[0] as Record)['agent'], + 'tools/pre-execute': args => (args[0] as Record)['agent'], + 'tools/result': args => (args[0] as Record)['agent'], +}) + +/** + * Resolve the routing key named by one scoped event payload. A null + * resolver means the payload cannot expose its external routing key, so the + * invariant checks carrier presence only. + * @param event - runtime Cordis event name. + * @returns the generated subject resolver, null for presence-only, + * or undefined when the event is not scope-filtered. + */ +export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined { + return scopedSubjectResolvers[event] +} diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts new file mode 100644 index 0000000000..6e39ab2025 --- /dev/null +++ b/packages/core/scope/tests/invariant.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(ScopeInvariant) + return ctx +} + +function emit(ctx: Context, receiver: object | undefined, event: string, args: unknown[]): void { + const dispatch = ctx.emit.bind(ctx) as (...values: unknown[]) => void + if (receiver === undefined) dispatch(event, ...args) + else dispatch(receiver, event, ...args) +} + +describe('scoped-dispatch invariants', () => { + it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => { + const ctx = await setup() + expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() + const agent = { id: 'a1' } + expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) + .toThrow(/dispatched without a scope carrier/) + }) + + it('checks every generated subject resolver against the carrier key', async () => { + const ctx = await setup() + const agent = { id: 'a1' } + const other = { id: 'a2' } + const rows: Array<[string, unknown[]]> = [ + ['agent/created', [agent]], + ['agent/disposed', [agent]], + ['agent/error', [agent, 1, 0, new Error('x')]], + ['agent/post-step', [agent, 1, 1]], + ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], + ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], + ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], + ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], + ['agent/request-error', [agent, 1, 1, new Error('x')]], + ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], + ['agent/session-start', [agent, 'startup']], + ['agent/status', [agent, 'idle']], + ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], + ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], + ['agent/turn-stop', [agent, 1]], + ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], + ['system-prompt/assemble', [[], { scope: agent }]], + ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], + ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], + ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], + ] + + for (const [event, args] of rows) { + expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} matching`).not.toThrow() + expect(() => { emit(ctx, scopeTarget(agent, other), event, args) }, `${event} mismatched`) + .toThrow(/DIFFERENT subject/) + } + }) + + it('requires carriers for generated presence-only scoped events without comparing a payload subject', async () => { + const ctx = await setup() + const agent = { id: 'a1' } + const rows: Array<[string, unknown[]]> = [ + ['session/created', [{}]], + ['session/disposed', [{}]], + ['session/event', [{}, {}]], + ['session/flush', [{}]], + ['subagent/end', [{}]], + ['subagent/start', [{}]], + ] + for (const [event, args] of rows) { + expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} carrier`).not.toThrow() + expect(() => { emit(ctx, undefined, event, args) }, `${event} no carrier`) + .toThrow(/dispatched without a scope carrier/) + } + }) +}) diff --git a/packages/core/scope/tsconfig.json b/packages/core/scope/tsconfig.json index 754725418e..9966c8ca8a 100644 --- a/packages/core/scope/tsconfig.json +++ b/packages/core/scope/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/scope/tsdown.config.ts b/packages/core/scope/tsdown.config.ts new file mode 100644 index 0000000000..1dbbf38372 --- /dev/null +++ b/packages/core/scope/tsdown.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + // Preserve the root entry's carrier WeakMap identity across bundles. + deps: { neverBundle: ['@deepseek-ai/dsh-scope'] }, + }, +]) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ba4e8ea94a..ee46c17590 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -2,6 +2,8 @@ Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction. +The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package. + ## Service: `SessionStore` (ctx key: `sessions`) Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle. @@ -34,7 +36,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback. -- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. +- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 540c5cdc75..314eb6e0b5 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts new file mode 100644 index 0000000000..2887d6f9f0 --- /dev/null +++ b/packages/core/session/src/invariant.ts @@ -0,0 +1,230 @@ +/** + * Package-owned relational invariants for the session event log. Load this + * companion beside `@deepseek-ai/dsh-invariants` to enable the checks. + * + * @module @deepseek-ai/dsh-session/invariant + */ + +import type { Context } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session' + +/** Cordis companion plugin name. */ +export const name = 'session-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants', 'sessions'] + +/** Per-session bookkeeping for relational log checks. */ +interface SessionTrace { + lastSeq: number + openTurn: number | null + openStep: number | null + nextTurn: number + nextStep: number + pendingCalls: Set +} + +/** One accepted event's deferred mutation of a committed session trace. */ +interface SessionTraceTransition { + scalars: Pick + pendingCalls: + | { kind: 'none' } + | { kind: 'add' | 'delete'; callId: CallId } + | { kind: 'clear' } +} + +/** Assert that a step-scoped event names the currently open turn and step. */ +function requireOpenStep( + trace: SessionTrace, + kind: string, + turn: number, + step: number, + fail: InvariantFailure, +): void { + if (trace.openTurn !== turn || trace.openStep !== step) { + fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`) + } +} + +/** Validate one candidate event without mutating the committed trace. */ +function validateEvent( + trace: SessionTrace, + event: SessionEvent, + fail: InvariantFailure, +): SessionTraceTransition { + if (event.seq <= trace.lastSeq) { + fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) + } + let openTurn = trace.openTurn + let openStep = trace.openStep + let nextTurn = trace.nextTurn + let nextStep = trace.nextStep + let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } + + // SessionEventMap is merge-extensible, so the default enforces turn + // enclosure for package-added events as well as the built-in variants. + switch (event.type) { + case 'turn/start': { + if (trace.openTurn !== null) { + fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`) + } + if (event.data.turn !== trace.nextTurn) { + fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`) + } + openTurn = event.data.turn + nextStep = 1 + break + } + case 'turn/end': { + if (trace.openTurn !== event.data.turn) { + fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`) + } + if (trace.openStep !== null) { + fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`) + } + openTurn = null + nextTurn += 1 + break + } + case 'step/start': { + if (trace.openTurn !== event.data.turn) { + fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } + if (trace.openStep !== null) { + fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`) + } + if (event.data.step !== trace.nextStep) { + fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`) + } + openStep = event.data.step + break + } + case 'step/end': { + requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail) + pendingCalls = { kind: 'clear' } + openStep = null + nextStep += 1 + break + } + case 'assistant/chunk': { + requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail) + break + } + case 'assistant/message': { + requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail) + break + } + case 'tool/call': { + requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail) + pendingCalls = { kind: 'add', callId: event.data.callId } + break + } + case 'tool/result': { + requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail) + const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' + if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { + fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`) + } + pendingCalls = { kind: 'delete', callId: event.data.callId } + break + } + default: { + if (trace.openTurn === null) { + fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) + } + break + } + } + return { + scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, + pendingCalls, + } +} + +/** Apply one already-validated transition after its event commits. */ +function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void { + Object.assign(trace, transition.scalars) + switch (transition.pendingCalls.kind) { + case 'none': + break + case 'add': + trace.pendingCalls.add(transition.pendingCalls.callId) + break + case 'delete': + trace.pendingCalls.delete(transition.pendingCalls.callId) + break + case 'clear': + trace.pendingCalls.clear() + break + /* v8 ignore next -- validateEvent produces this closed transition union */ + default: + assertNever(transition.pendingCalls, 'session trace pending-call transition') + } +} + +/** Install the session contribution into its child registration fiber. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const stagedTransitions = new WeakMap() + + const freshTrace = (): SessionTrace => ({ + lastSeq: -1, + openTurn: null, + openStep: null, + nextTurn: 1, + nextStep: 1, + pendingCalls: new Set(), + }) + + const seedSession = (session: Session): SessionTrace => { + const trace = freshTrace() + traces.set(session, trace) + for (const event of session.events) { + applyTransition(trace, validateEvent(trace, event, fail)) + } + return trace + } + + /* v8 ignore next -- session/event always follows list() or session/created seeding */ + const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session) + + for (const session of ctx.sessions.list()) seedSession(session) + + ctx.on('session/created', (session) => { seedSession(session) }, { global: true }) + + ctx.on('session/event', (session, event) => { + const staged = stagedTransitions.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ + if (staged === undefined || staged.session !== session) { + return fail('session/event reached publication without matching pre-commit validation') + } + stagedTransitions.delete(event) + applyTransition(staged.trace, staged.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) + const transition = validateEvent(trace, event, fail) + // A later dispatch listener may veto. Validation is pure, so abandoning + // this weakly keyed transition does not advance or retain the session. + stagedTransitions.set(event, { session, trace, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the session invariant companion. + * @param ctx - Cordis context carrying the invariant and session services. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts new file mode 100644 index 0000000000..71f6dc9ace --- /dev/null +++ b/packages/core/session/tests/invariant.spec.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise<{ ctx: Context; fiber: Awaited> }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(SessionInvariant) + return { ctx, fiber } +} + +describe('session-log invariants', () => { + it('keeps registration global when the companion is mounted under a scope', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + let scopedCtx!: Context + await ctx.plugin(Object.assign((inner: Context) => { + scopedCtx = createScope(inner, {}).ctx + }, { inject: ['sessions', 'invariants'] })) + await scopedCtx.plugin(SessionInvariant) + const session = ctx.sessions.create(SessionId('global-under-scoped-invariants')) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('accepts a well-formed turn, step, and tool sequence', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) + session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('does not advance committed trace state when a later dispatch listener vetoes', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('dispatch-veto-rollback')) + let veto = true + ctx.on('internal/dispatch', (_mode, name) => { + if (name !== 'session/event' || !veto) return + veto = false + throw new Error('later dispatch veto') + }) + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('later dispatch veto') + expect(session.events).toEqual([]) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('applies the committed transition after another postcommit observer throws', async () => { + const { ctx } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('postcommit-peer')) + ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + expect(warnings).toHaveLength(2) + }) + + it('rejects non-monotonic event sequence numbers', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + ctx.emit(scopeTarget(session, undefined), 'session/event', session, { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + } as never) + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { + type: 'turn/end', + seq: 0, + time: 2, + data: { turn: 1, reason: { kind: 'completed' } }, + } as never) }).toThrow(/seq must strictly increase/) + }) + + it('enforces turn numbering and enclosure', async () => { + const first = await setup() + const open = first.ctx.sessions.create() + open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) + .toThrow(/does not match open turn 1/) + + const second = (await setup()).ctx.sessions.create() + second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + second.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/expected turn 2, got 3/) + + const outside = (await setup()).ctx.sessions.create() + expect(() => outside.append('user/message', { + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) + expect(() => outside.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'go' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) + // Merge-extensible session events use the same default enclosure branch. + const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown + expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/) + }) + + it('enforces open-step identity and numbering', async () => { + const wrongTurn = (await setup()).ctx.sessions.create() + wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) + + const nested = (await setup()).ctx.sessions.create() + nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + nested.append('step/start', { turn: 1, step: 1 }) + expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) + expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) + .toThrow(/while step 1 is still open/) + expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/) + expect(() => nested.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 2, + content: [], + }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/) + + const skipped = (await setup()).ctx.sessions.create() + skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + skipped.append('step/start', { turn: 1, step: 1 }) + skipped.append('step/end', { turn: 1, step: 1 }) + expect(() => skipped.append('step/start', { turn: 1, step: 3 })) + .toThrow(/expected step 2 in turn 1, got 3/) + }) + + it('requires step-scoped stream and tool events to name the open step', async () => { + const chunk = (await setup()).ctx.sessions.create() + chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => chunk.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'x' }, + })).toThrow(/open is turn 1\/step null/) + + const tool = (await setup()).ctx.sessions.create() + tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + tool.append('step/start', { turn: 1, step: 1 }) + expect(() => tool.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('ghost'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/) + }) + + it('allows interrupted repair results and unresolved calls at step end', async () => { + const repaired = (await setup()).ctx.sessions.create() + expect(() => { + repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + repaired.append('step/start', { turn: 1, step: 1 }) + repaired.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('crashed'), + content: [], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, + }, { surfaceOp: 'append' }) + repaired.append('step/end', { turn: 1, step: 1 }) + repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) + }).not.toThrow() + + const unresolved = (await setup()).ctx.sessions.create() + expect(() => { + unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + unresolved.append('step/start', { turn: 1, step: 1 }) + unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + unresolved.append('step/end', { turn: 1, step: 1 }) + unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + }).not.toThrow() + }) + + it('does not let a result in a later step satisfy an earlier call', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + expect(() => session.append('tool/result', { + turn: 1, + step: 2, + callId: CallId('c1'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/) + }) + + it('replays seeded sessions and tracks each session independently', async () => { + const { ctx } = await setup() + const badSeed = [ + { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + ] + expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) + + const a = ctx.sessions.create(SessionId('a')) + const b = ctx.sessions.create(SessionId('b')) + a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + .not.toThrow() + }) + + it('rebuilds trace state for sessions that exist when the companion reloads', async () => { + const { ctx, fiber } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + await fiber.dispose() + await ctx.plugin(SessionInvariant) + expect(() => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'h' }, + })).not.toThrow() + expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + }) + + it('removes all listeners when the companion is disposed', async () => { + const { ctx, fiber } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(() => session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).not.toThrow() + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index b19b98c5ad..253a1c8793 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/session/tsdown.config.ts b/packages/core/session/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/session/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 409aef5ef2..de24d81a00 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -18,7 +18,12 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events @deepseek-ai/dsh-tasks generic background-task registry -@deepseek-ai/dsh-invariants dev-mode event-contract assertions +@deepseek-ai/dsh-invariants configurable invariant registry service +@deepseek-ai/dsh-session/invariant +@deepseek-ai/dsh-agent/invariant +@deepseek-ai/dsh-scope/invariant +@deepseek-ai/dsh-agent-loop/invariant + package-owned relational checks @deepseek-ai/dsh-tool-bash the model-facing bash schema @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @@ -42,11 +47,13 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. + +For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. ## Why a code bundle, not a shared YAML include @@ -59,4 +66,4 @@ Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and ## Known Limitations and Deferred Work - **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. -- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. +- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 772d6c059b..cae0aab6d4 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a4965ca457..6849b21f2e 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -19,7 +19,11 @@ import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/d import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' import TaskService from '@deepseek-ai/dsh-tasks' -import * as invariants from '@deepseek-ai/dsh-invariants' +import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' +import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' +import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -48,7 +52,8 @@ export interface SkillConfig { * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -75,6 +80,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task control-tool wait bounds. */ toolTasks?: toolTasks.Config + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig } /** The skill config schema exported for app packages that forward `skills`. */ @@ -101,7 +108,8 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: ToolTasksConfigSchema, - }) as unknown as z>, + invariants: InvariantService.Config, + }) as unknown as z>, ]) as unknown as z /** @@ -120,6 +128,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() + expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() await ctx.fiber.dispose() }) + it('mounts package companions and forwards invariant selection config', async () => { + const nestedTurn = (ctx: Context): void => { + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + } + + const enabled = await mount({ workspaceContext: false }) + expect(() => { nestedTurn(enabled) }).toThrow(/turn 1 is still open/) + await enabled.fiber.dispose() + + for (const invariants of [ + { enabled: false }, + { package_allowlist: ['^@deepseek-ai/dsh-agent$'] }, + { package_blocklist: ['^@deepseek-ai/dsh-session$'] }, + ]) { + const filtered = await mount({ workspaceContext: false, invariants }) + expect(() => { nestedTurn(filtered) }).not.toThrow() + await filtered.fiber.dispose() + } + }) + it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { const ctx = await mount({ workspaceContext: false }) @@ -355,6 +382,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: {}, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + invariants: { enabled: false }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -366,6 +394,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: {}, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, + invariants: appConfig.invariants, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) @@ -426,4 +455,16 @@ describe('dsh-agent-spine-demo bundle', () => { expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) + + it('keeps every invariant companion loadable through the real Loader unwrap path', () => { + const loader = Object.create(Loader.prototype) as Loader + for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) { + expect('default' in companion).toBe(false) + const unwrapped = loader.unwrapExports(companion) as Record + expect(unwrapped).toBe(companion) + expect(typeof unwrapped.name).toBe('string') + expect(unwrapped.inject).toContain('invariants') + expect(typeof unwrapped.apply).toBe('function') + } + }) }) diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..18b3b9b33c 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -19,7 +19,7 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') // Symlink each required workspace package by package name so plain Node resolves its built `main`, // matching an installed dependency rather than tsconfig paths. const dshPackages = [ - 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', + 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 44e7d80a05..bd17fdc981 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -46,7 +46,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. -- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. +- `HarnessError` — base class for the product error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf product package every other product package imports, so a single base is shared without a new dependency edge. Per-package errors such as `LlmError` and `ToolArgsError` extend it. `isHarnessError(value)` narrows at seams. - `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. diff --git a/packages/sdk/helper/src/features/builtin/helpers.ts b/packages/sdk/helper/src/features/builtin/helpers.ts index 60f7530c61..9b21c7f64f 100644 --- a/packages/sdk/helper/src/features/builtin/helpers.ts +++ b/packages/sdk/helper/src/features/builtin/helpers.ts @@ -15,8 +15,19 @@ import type { PackageScriptResource, } from '../resources.ts' +/** Return the installable package name for a bare package or package subpath. */ +function installablePackageName(specifier: string): string { + const segments = specifier.split('/') + const expectedSegments = specifier.startsWith('@') ? 2 : 1 + if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) { + throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`) + } + return segments.slice(0, expectedSegments).join('/') +} + /** Create a runtime NPM dependency resource. */ -function npmDependency(_owner: string, name: string): NpmDependencyResource { +function npmDependency(_owner: string, specifier: string): NpmDependencyResource { + const name = installablePackageName(specifier) return { kind: 'npm-dependency', key: resourceKey(`npm-dependency:${name}`), @@ -52,7 +63,7 @@ export function cordisConfigEntry( } } -/** Couple one bare-package Cordis config entry to its mandatory runtime NPM dependency. */ +/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */ export function npmCordisConfigEntry( owner: string, value: CordisConfigEntry, diff --git a/packages/sdk/helper/src/features/builtin/spine.ts b/packages/sdk/helper/src/features/builtin/spine.ts index caf066865e..3e1acb98fb 100644 --- a/packages/sdk/helper/src/features/builtin/spine.ts +++ b/packages/sdk/helper/src/features/builtin/spine.ts @@ -9,7 +9,7 @@ import type { ProjectProfile } from '../../project/types.ts' import { loadHelperTemplate } from '../../templates/template-assets.ts' import { FeatureOption, FixedFeature } from '../feature.ts' import { ProjectContribution } from '../resources.ts' -import { npmCordisConfigEntry, requiredString } from './helpers.ts' +import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts' const ID = featureId('spine') const PERSONA = loadHelperTemplate>('persona.txt.tpl').render({}).trimEnd() @@ -37,6 +37,10 @@ class SpineOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []), ...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }), ...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }), + cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }), + cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }), + ...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }), + cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }), ...npmCordisConfigEntry(ID, { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 655b308911..94fd212a68 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -188,9 +188,15 @@ describe('SdkProject and ProjectEditSession', () => { .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) + expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') + expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') + expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant') + expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant') expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2') expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15') + expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1') + expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') @@ -865,6 +871,11 @@ describe('extension points', () => { expect(stringArray({ value: [1] }, 'value')).toHaveLength(1) expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([]) expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([]) + expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg') + expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg') + for (const invalid of ['', '@scope', '@scope/']) { + expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier') + } expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value') const builtins = createBuiltinRegistry(profile) expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed']) diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index df3b74d346..6f091c2bf6 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -3,7 +3,10 @@ import { Context } from 'cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -11,6 +14,13 @@ import * as fork from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) } @@ -24,7 +34,7 @@ function start(ctx: Context, provider: string, request: Omit[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) } @@ -24,14 +34,14 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] /** * Drives the REAL fork backend with a real loop + scripted mock MODEL + the - * real dsh-invariants plugin. The plugin replays a seeded child log on + * real invariant service and package companions. The session contribution replays a seeded child log on * `session/created`, so a malformed (unbalanced) fork seed makes these tests * THROW — that is the regression guard for the completed-turn-prefix boundary. */ async function setup(script: Script) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e50457bcb4..58b83859c5 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -5,7 +5,10 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' @@ -18,6 +21,13 @@ import { type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + interface CodeRunRequestLike { bindings: { global: string; functions: Record Promise> }[] } @@ -51,7 +61,7 @@ async function setup(script: Script, options: SetupOptions = {}) { run: options.codeRun ?? (() => Promise.resolve({ logs: [] })), } as never) } - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const disposeProvider = ctx.subagents.registerProvider({ diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 13029ef3e7..3cba1b3a91 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -4,17 +4,27 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function setup(script: Script) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7de5f6f4d6..7ba0551f34 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -5,7 +5,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' @@ -13,10 +16,17 @@ import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + /** * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock * MODEL (the only mocked boundary) + the real SubagentService + the real - * dsh-invariants plugin (so a malformed child session log would fail the test). + * invariant service plus package companions (so a malformed child session log would fail the test). * The parent is a real config agent; the spawn provider creates a real child * agent on the same context and we assert its output. */ @@ -24,7 +34,7 @@ async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -295,7 +305,7 @@ describe('dsh-subagent-spawn', () => { const ctx = new Context() const adapter = new MockAdapter(['hang']) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 661e073d13..1cb0e57838 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,62 +1,63 @@ # dsh-invariants -Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior. +Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions. -The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. +## Service: `InvariantService` (`ctx.invariants`) -Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own. +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` -Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only. +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. -## Plugin +Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. -A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does): +`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically. + +The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. + +`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service. + +## Package companions + +| Companion | Registration | Checks | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | + +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. + +## Composition ```ts import type { Context } from 'cordis' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' declare const ctx: Context -await ctx.plugin(Invariants) +ctx.plugin(InvariantService, { + enabled: true, + package_allowlist: ['^@deepseek-ai/dsh-'], + package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'], +}) +ctx.plugin(SessionInvariant) ``` -`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration. - -## Invariants asserted - -Session log (per session): - -- **`seq` strictly increases** — the spine of replay equivalence. -- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. -- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. -- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. -- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal). -- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance. - -Agent status (per agent): - -- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations. - -Model requests (on `llm/stream`): - -- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. - -On any violation it throws `InvariantError` (`code: 'INVARIANT'`). - -## Why runtime assertions remain useful - -Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships wherever it is mounted while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). - -## Seeded sessions - -A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state. +The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. ## Model Experience -None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. +None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results. ## Known Limitations and Deferred Work -- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped. -- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is. +- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one. +- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. +- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 59a425387b..e06e9a3db7 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-invariants", - "description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics", + "description": "Registry service for package-owned DeepSeek Harness runtime invariants", "version": "0.0.1", "private": true, "type": "module", @@ -22,21 +22,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index cdb06edbac..754cb46ab9 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,409 +1,199 @@ /** - * Runtime listeners that fail loudly when cross-event contracts are broken: - * turn and step nesting, scoped dispatch, status transitions, and request - * reconstruction. The plugin has no environment guard and is active wherever - * mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions - * may omit it. Sessions own immutable, surface-valid event storage; this plugin - * checks only relationships that event acceptance cannot express. + * Configurable registry for package-owned runtime invariant contributions. + * Packages register checks from optional `./invariant` companion plugins; + * ordinary package entrypoints stay independent of diagnostics. + * * @module @deepseek-ai/dsh-invariants */ -import type { Context } from 'cordis' -import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' -import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { scopedSubjectResolverFor } from './scoped-events.generated.ts' +import { Context, Service } from 'cordis' +import type { Inject } from 'cordis' +import z from 'schemastery' +import type Schema from 'schemastery' -export const name = 'invariants' -export const inject = ['sessions'] - -/** - * Thrown when a harness event-contract invariant is violated. Extends - * {@link HarnessError} (`code: 'INVARIANT'`) so a violation is routable like - * any other harness failure. - */ -export class InvariantError extends HarnessError { - constructor(message: string) { - super(`invariant violated: ${message}`, 'INVARIANT') - this.name = 'InvariantError' - } +/** Runtime invariant selection configured on the service plugin. */ +export interface Config { + /** Global switch; defaults to `true`. */ + readonly enabled?: boolean + /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */ + readonly package_allowlist?: string[] + /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */ + readonly package_blocklist?: string[] } -/** Per-session bookkeeping for the session-log invariants. */ -interface SessionTrace { - /** Highest `seq` seen so far (must strictly increase). */ - lastSeq: number - /** Open turn number, or null between turns. */ - openTurn: number | null - /** Open step within the current turn, or null between steps. */ - openStep: number | null - /** The next turn number expected in this session log. */ - nextTurn: number - /** The next step number expected within the open turn. */ - nextStep: number +/** + * Throw a package-attributed invariant failure. + * @param message - violated package contract without the standard prefix. + * @returns never because reporting a violation throws. + */ +export type InvariantFailure = (message: string) => never + +/** Install one package's listeners into the registration's child context. */ +export interface InvariantInstaller { /** - * Tool-call ids issued in the OPEN step awaiting a result. Cleared at - * `step/end` — a result must arrive in the same step as its call. + * Install the package contribution. + * @param ctx - child context owned by this invariant registration. + * @param fail - reporter bound to the registering package name. + * @returns nothing after synchronous listener installation completes. */ - pendingCalls: Set + (ctx: Context, fail: InvariantFailure): void + /** Services the child installer fiber may access. */ + readonly inject?: Inject } -/** One accepted event's deferred mutation of a live session trace. */ -interface SessionTraceTransition { - /** Scalar state after the event commits. */ - scalars: Pick - /** The event's mutation of the open step's pending call set. */ - pendingCalls: - | { kind: 'none' } - | { kind: 'add' | 'delete'; callId: CallId } - | { kind: 'clear' } +/** Internal effect shape used to join child startup before a companion loads. */ +interface PendingInvariantRegistration extends PromiseLike<() => void> { + (): void | Promise } -/** Assert that a step-scoped event names the currently open turn and step. */ -function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void { - if (trace.openTurn !== turn || trace.openStep !== step) { - throw new InvariantError( - `${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`, - ) +/** Thrown when a package-owned runtime invariant is violated. */ +export class InvariantError extends Error { + /** Stable machine-readable invariant failure code. */ + readonly code = 'INVARIANT' as const + /** Full npm package name that owns the violated invariant. */ + readonly packageName: string + + /** + * Construct a package-attributed invariant failure. + * @param packageName - full npm package name that registered the check. + * @param message - violated contract, without the standard error prefix. + */ + constructor(packageName: string, message: string) { + super(`invariant violated by "${packageName}": ${message}`) + this.name = 'InvariantError' + this.packageName = packageName } } -/** Validate one candidate event without mutating the committed session trace. */ -function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition { - // seq is strictly monotonic — the spine of replay equivalence. lastSeq - // starts at -1, so the first event (seq 0) passes. - if (event.seq <= trace.lastSeq) { - throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) - } - let openTurn = trace.openTurn - let openStep = trace.openStep - let nextTurn = trace.nextTurn - let nextStep = trace.nextStep - let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - - // Boundary/step-scoped events have explicit cases; every OTHER event type — - // including plugin-added (merge-extensible) SessionEventMap keys — is caught - // by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an - // unknown variant is valid, not a compile error. - switch (event.type) { - case 'turn/start': { - if (trace.openTurn !== null) { - throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`) - } - // Current sessions replay full logs, so numbering starts at 1 and remains - // contiguous. If a future compaction/fork stores a partial log, it must - // seed `nextTurn` from retained metadata before this check runs. - if (event.data.turn !== trace.nextTurn) { - throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`) - } - openTurn = event.data.turn - nextStep = 1 - break - } - case 'turn/end': { - if (trace.openTurn !== event.data.turn) { - throw new InvariantError(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`) - } - if (trace.openStep !== null) { - throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`) - } - openTurn = null - nextTurn += 1 - break - } - case 'step/start': { - if (trace.openTurn !== event.data.turn) { - throw new InvariantError(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`) - } - if (trace.openStep !== null) { - throw new InvariantError(`step/start ${event.data.step} while step ${trace.openStep} is still open`) - } - // Steps are checked under the same full-log assumption as turns above. - if (event.data.step !== trace.nextStep) { - throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`) - } - openStep = event.data.step - break - } - case 'step/end': { - requireOpenStep(trace, 'step/end', event.data.turn, event.data.step) - // A result must arrive in the step that issued the call; orphan calls - // (a step that errored before its result) do not carry to the next step. - pendingCalls = { kind: 'clear' } - openStep = null - nextStep += 1 - break - } - case 'assistant/chunk': { - requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step) - break - } - case 'assistant/message': { - requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step) - break - } - case 'tool/call': { - requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step) - pendingCalls = { kind: 'add', callId: event.data.callId } - break - } - case 'tool/result': { - requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) - // A result needs a prior matching call in the same step. (The converse - // does NOT hold: a call may have no result — a throwing tool-execution - // pipeline step ends the turn with no tool/result, which is legal.) - const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' - if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { - throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) - } - pendingCalls = { kind: 'delete', callId: event.data.callId } - break - } - // Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary - // case above must sit inside an open turn. The durable session log uses the - // turn as its commit/replay boundary (the JSONL backend treats anything - // after the last turn/end as a crash tail), so a bare event between turns is - // silently dropped on reload. The loop records queued user messages after - // turn/start, and an idle agent.inject() wraps its context/message in a - // one-shot turn. A `default` - // (not an enumerated list) is deliberate: SessionEventMap is - // merge-extensible, so a PLUGIN-added event type appended while idle must - // also fail here rather than fall through and be dropped on resume. - default: { - if (trace.openTurn === null) { - throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) - } - break - } - } - return { - scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, - pendingCalls, +declare module 'cordis' { + interface Context { + invariants: InvariantService } } -/** Apply one already-validated transition after its event commits. */ -function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void { - Object.assign(trace, transition.scalars) - switch (transition.pendingCalls.kind) { - case 'none': - break - case 'add': - trace.pendingCalls.add(transition.pendingCalls.callId) - break - case 'delete': - trace.pendingCalls.delete(transition.pendingCalls.callId) - break - case 'clear': - trace.pendingCalls.clear() - break - /* v8 ignore next -- validateEvent produces this closed transition union */ - default: - assertNever(transition.pendingCalls, 'session trace pending-call transition') - } +/** Compile and validate one package-filter list. */ +function compilePatterns(field: 'package_allowlist' | 'package_blocklist', values: readonly string[]): RegExp[] { + const seen = new Set() + return values.map((value) => { + if (value.length === 0 || value.trim() !== value) { + throw new Error(`invariants: ${field} entries must be non-blank and have no surrounding whitespace`) + } + if (seen.has(value)) { + throw new Error(`invariants: ${field} contains duplicate regex ${JSON.stringify(value)}`) + } + seen.add(value) + try { + return new RegExp(value) + } catch (cause) { + throw new Error(`invariants: ${field} contains invalid regex ${JSON.stringify(value)}`, { cause }) + } + }) } -/** Validate and apply one event while rebuilding an already-committed log. */ -function replayEvent(trace: SessionTrace, event: SessionEvent): void { - applyTransition(trace, validateEvent(trace, event)) -} - -/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */ -function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { - if (from === undefined) return - if (from === to) { - throw new InvariantError(`agent/status repeated ${to} (no-op transition)`) - } - if (from === 'disposed') { - throw new InvariantError(`agent/status left terminal state disposed → ${to}`) - } -} - -/** - * Register the runtime invariants. Contributions are effect-scoped, so - * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply - * the trace state is rebuilt by replaying each existing session's log, so a - * hot reload mid-turn does not falsely reject the next event. - * - * @param ctx - Cordis context that receives the invariant listeners. - */ -export function apply(ctx: Context): void { - const traces = new WeakMap() - const stagedTransitions = new WeakMap() - // Agent status has no stored history to replay; the first observation after - // (re-)apply seeds the baseline, so a reload never produces a false positive. - const lastStatus = new WeakMap() - - const freshTrace = (): SessionTrace => ({ - lastSeq: -1, - openTurn: null, - openStep: null, - nextTurn: 1, - nextStep: 1, - pendingCalls: new Set(), +/** Package-owned invariant registry with global and regex-based selection. */ +export class InvariantService extends Service { + static Config: Schema = z.object({ + enabled: z.boolean().default(true), + package_allowlist: z.array(z.string()).default([]), + package_blocklist: z.array(z.string()).default([]), }) - /** Build (or rebuild) a session's trace by replaying its whole log. */ - const seedSession = (session: Session): SessionTrace => { - const trace = freshTrace() - traces.set(session, trace) - for (const event of session.events) { - replayEvent(trace, event) - } - return trace + private readonly enabled: boolean + private readonly ownerCtx: Context + private readonly packageAllowlist: readonly RegExp[] + private readonly packageBlocklist: readonly RegExp[] + private readonly registrations = new Set() + + /** + * Create and install the invariant registry. + * @param ctx - Cordis context that owns the service. + * @param config - global enablement and package-name regex filters. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'invariants') + this.ownerCtx = ctx + this.enabled = config.enabled ?? true + this.packageAllowlist = compilePatterns('package_allowlist', config.package_allowlist ?? []) + this.packageBlocklist = compilePatterns('package_blocklist', config.package_blocklist ?? []) } - // Every store-created session (the only kind that emits session/event) is - // seeded first — via ctx.sessions.list() at apply or session/created — so the - // fallback is a defensive guard, never hit in practice. - /* v8 ignore next -- traceFor's fallback: session/event always follows a seed */ - const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session) + /** Return whether one full package name passes the configured filters. */ + private selected(packageName: string): boolean { + if (!this.enabled) return false + if (this.packageAllowlist.length > 0 + && !this.packageAllowlist.some(pattern => pattern.test(packageName))) return false + return !this.packageBlocklist.some(pattern => pattern.test(packageName)) + } - // Rebuild state for sessions that already exist at (re-)apply time — HMR - // reload starts a fresh fiber, and a mid-turn session would otherwise look - // like it began with a stray chunk/step-end. - for (const session of ctx.sessions.list()) seedSession(session) - - // A newly created session may arrive seeded/forked (the constructor copies - // the seed WITHOUT emitting session/event), so replay its log here too. - ctx.on('session/created', (session) => { seedSession(session) }, { global: true }) - - ctx.on('session/event', (session, event) => { - // Session resolves dispatch before committing, so internal/dispatch has - // already staged this exact event. A later dispatch veto skips every - // session/event callback and therefore leaves the live trace unchanged. - const staged = stagedTransitions.get(event) - /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ - if (staged === undefined || staged.session !== session) { - throw new InvariantError('session/event reached publication without matching pre-commit validation') + /** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ + register(packageName: string, installer: InvariantInstaller): () => void { + if (packageName.length === 0 || packageName.trim() !== packageName || /\s/.test(packageName)) { + throw new Error('invariants: packageName must be non-blank and contain no whitespace') } - stagedTransitions.delete(event) - applyTransition(staged.trace, staged.transition) - }, { global: true }) - - ctx.on('agent/status', (agent, status) => { - checkTransition(lastStatus.get(agent), status) - lastStatus.set(agent, status) - }, { global: true }) - - // --- Scoped-dispatch invariants (the agent-scoping seam) --------------- - // - // Every scope-filtered event family must dispatch with a scope carrier - // (scopeTarget) whose key IS the subject the event's arguments name — - // a dispatch without one silently reverts that event to global delivery - // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one - // delivers to the wrong agent's listeners. `internal/dispatch` fires - // synchronously before listener delivery, so a violation throws at the - // dispatching call site. The generated table maps each family to the unique - // payload path whose Program type matches the real scopeTarget routing key; - // `null` means the key is external to the payload, so only carrier presence - // can be asserted. - ctx.on('internal/dispatch', (_mode, name, args, thisArg) => { - const subjectOf = scopedSubjectResolverFor(name) - if (subjectOf === undefined) return - if (!isScopeCarrier(thisArg)) { - throw new InvariantError( - `"${name}" is a scope-filtered event but was dispatched without a scope carrier — ` - + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))') - } - if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) { - throw new InvariantError( - `"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` - + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))') - } - if (name === 'session/event') { - const [session, event] = args as [Session, SessionEvent] - const trace = traceFor(session) - const transition = validateEvent(trace, event) - // The exact event identity reaches the contained post-commit listener. - // A later internal/dispatch listener may still veto; because validation - // is pure, abandoning this weakly keyed transition does not advance the - // committed trace or retain the session. - stagedTransitions.set(event, { session, trace, transition }) - } - }, { global: true }) - - // Request-reconstruction cross-check (the reconstructability RFC): a - // loop-built request — frozen envelope + live sessionId is the marker; a - // hand-built one-shot (compaction summarize) is unfrozen and skipped — must - // be EXACTLY what the session log reconstructs: - // - // - messages: the folded header's session prefix (messagePrefix — the - // `agent/session-prefix` product, logged on the header because no - // session event carries it) followed by the - // derivation over the log prefix strictly before the in-flight step's - // `step/start` (the reconstruction boundary). The derivation is compared - // against a FRESH Session built over that prefix — the same projection - // code with zero shared state, so the live cache under test cannot vouch - // for itself. Boundary-correct by construction: content appended after - // the boundary (an `agent/request`-window inject) is legitimately absent - // from this request, and a current-surface comparison would false-fire. - // - header: every non-content field must equal the fold of the log's - // `request/header` events — the loop logs the header event BEFORE - // dispatch, so the fold already covers this request. - // - // Registered with `prepend: true` so a short-circuiting llm/stream listener - // (the replay adapter returns its chunks without calling next()) cannot - // silence the check by registering first. Prepend beats APPEND-registered - // listeners only — two prepended listeners have no defined mutual order - // (cordis unshift) — which is fine: correctness rests on the seq-bounded - // fold below, never on listener timing. - ctx.on('llm/stream', (options: GenerateOptions, next) => { - if (options.sessionId === undefined || !Object.isFrozen(options)) return next() - // GenerateOptions types sessionId as Branded<'SessionId'>, which IS - // SessionId (dsh-llm cannot import it without a cycle) — no cast needed. - const session = ctx.sessions.get(options.sessionId) - if (!session) return next() - if (!Object.isFrozen(options.messages)) { - throw new InvariantError('a loop-built request must carry a frozen messages array') + if (this.registrations.has(packageName)) { + throw new Error(`invariants: package "${packageName}" is already registered`) } - const events = session.events - // seq === index (checked above), so the last step/start's seq bounds the - // prefix directly. The in-flight step's step/start is necessarily the - // last one: the loop cannot open another step while this call streams. - let boundary = -1 - for (let i = events.length - 1; i >= 0; i -= 1) { - if (events[i]?.type === 'step/start') { - boundary = i - break - } - } - if (boundary === -1) { - throw new InvariantError('a loop-built request with no step/start in its session log') - } - const header = foldRequestHeader(events) - if (header === undefined) { - throw new InvariantError('a loop-built request with no request/header event in its session log') - } - const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) - // The reconstruction equation: the folded header's session prefix, then - // the boundary derivation — the loop - // logs the header event BEFORE dispatch, so the fold already covers this - // request's prefix. JSON equality is sound here: both sides are - // structuredClones produced by the same projection/build code path, so key - // insertion order matches when the values do. - const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] - if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) - } + // Service method tracing binds `this.ctx` to the caller. This explicit + // origin keeps registrations and their child fibers owned by the service; + // companion disposal is covered independently by the returned disposer. + const ctx = this.ownerCtx + const registrations = this.registrations + registrations.add(packageName) - const headerMatches = options.model === header.config.model - && options.system === header.system - && options.temperature === header.config.temperature - && options.maxTokens === header.config.maxTokens - && JSON.stringify(options.stop) === JSON.stringify(header.config.stop) - && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? []) - if (!headerMatches) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`) + let registration: PendingInvariantRegistration + try { + registration = ctx.effect(async () => { + if (!this.selected(packageName)) { + return () => { + registrations.delete(packageName) + } + } + + const installInvariant = (childCtx: Context) => { + installer(childCtx, (message): never => { + throw new InvariantError(packageName, message) + }) + } + const child = ctx.plugin(installer.inject === undefined + ? installInvariant + : Object.assign(installInvariant, { inject: installer.inject })) + + try { + await child + } catch (error) { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + throw error + } + + return async () => { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + } + }, `invariants.register(${JSON.stringify(packageName)})`) + } catch (error) { + registrations.delete(packageName) + throw error } - return next() - }, { global: true, prepend: true }) + // Cordis attaches setup thenability and async teardown to this callable; + // the service seam intentionally exposes only the conventional disposer. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private. + return registration + } } + +export default InvariantService diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts deleted file mode 100644 index 36d1721945..0000000000 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Generated scoped-event routing-subject resolvers for dsh-invariants. - * Do not edit by hand; run `pnpm run gen-scoped-events`. - * - * @module @deepseek-ai/dsh-invariants/scoped-events.generated - */ - -import type { Events } from 'cordis' -import type { Scoped } from '@deepseek-ai/dsh-scope' -import type {} from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-subagent' -import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-user-approval' - -type ScopedEventName = { - [K in keyof Events]: ThisParameterType extends Scoped ? K : never -}[keyof Events] - -type ScopedSubjectResolver = (args: readonly unknown[]) => unknown - -function adapt( - resolver: (args: Parameters) => unknown, -): ScopedSubjectResolver { - return args => resolver(args as Parameters) -} - -const scopedSubjectResolvers = Object.freeze({ - 'agent/created': adapt<'agent/created'>(args => args[0]), - 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), - 'agent/error': adapt<'agent/error'>(args => args[0]), - 'agent/post-step': adapt<'agent/post-step'>(args => args[0]), - 'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]), - 'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]), - 'agent/queued': adapt<'agent/queued'>(args => args[0]), - 'agent/request': adapt<'agent/request'>(args => args[0]), - 'agent/request-error': adapt<'agent/request-error'>(args => args[0]), - 'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]), - 'agent/session-start': adapt<'agent/session-start'>(args => args[0]), - 'agent/status': adapt<'agent/status'>(args => args[0]), - 'agent/step-result': adapt<'agent/step-result'>(args => args[0]), - 'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]), - 'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]), - 'approval/request': adapt<'approval/request'>(args => args[0].agent), - 'session/created': null, - 'session/disposed': null, - 'session/event': null, - 'session/flush': null, - 'subagent/end': null, - 'subagent/start': null, - 'system-prompt/assemble': adapt<'system-prompt/assemble'>(args => args[1].scope), - 'tools/execute': adapt<'tools/execute'>(args => args[0].agent), - 'tools/post-execute': adapt<'tools/post-execute'>(args => args[0].agent), - 'tools/pre-execute': adapt<'tools/pre-execute'>(args => args[0].agent), - 'tools/result': adapt<'tools/result'>(args => args[0].agent), -} as const satisfies Readonly>) - -const scopedSubjectResolverIndex: Readonly> = scopedSubjectResolvers - -/** - * Resolve the routing key named by one scoped event payload. A null - * resolver means the payload cannot expose its external routing key, so the - * invariant checks carrier presence only. - * @param event - runtime Cordis event name. - * @returns the generated subject resolver, null for presence-only, - * or undefined when the event is not scope-filtered. - */ -export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined { - return scopedSubjectResolverIndex[event] -} diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts deleted file mode 100644 index de0046c00e..0000000000 --- a/packages/support/invariants/tests/invariants.spec.ts +++ /dev/null @@ -1,851 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' -import { CallId } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import * as Invariants from '@deepseek-ai/dsh-invariants' -import { InvariantError } from '@deepseek-ai/dsh-invariants' - -/** A Context with the session store and the invariants plugin registered. */ -async function setup() { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(Invariants) - return { ctx, fiber } -} - -/** A minimal Agent stand-in for agent/status emission. */ -function mockAgent(id: string): Agent { - return { id } as unknown as Agent -} - -describe('session-log invariants', () => { - it('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - let scopedCtx!: Context - await ctx.plugin(Object.assign((inner: Context) => { - scopedCtx = createScope(inner, {}).ctx - }, { inject: ['sessions'] })) - await scopedCtx.plugin(Invariants) - const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants')) - - expect(() => { - globalSession.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('accepts a well-formed turn/step/tool sequence', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('does not advance the trace when a later internal-dispatch listener vetoes', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('dispatch-veto-rollback')) - let veto = true - ctx.on('internal/dispatch', (_mode, name) => { - if (name !== 'session/event' || !veto) return - veto = false - throw new Error('later dispatch veto') - }) - - expect(() => session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - })).toThrow('later dispatch veto') - expect(session.events).toEqual([]) - - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - }) - - it('applies the committed transition after a prepended observer throws', async () => { - const { ctx } = await setup() - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const session = ctx.sessions.create(SessionId('postcommit-peer')) - ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) - - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - expect(warnings).toEqual([ - 'session "postcommit-peer": session/event listener threw: Error: hostile observer', - 'session "postcommit-peer": session/event listener threw: Error: hostile observer', - ]) - }) - - it('rejects a non-monotonic seq (replay spine)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // Session.append enforces seq-contiguity at the source, so drive the - // invariants seq check directly via session/event with a regressing seq. - ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) }) - .toThrow(/seq must strictly increase/) - }) - - it('rejects a turn/start while another turn is open', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) - .toThrow(/turn 1 is still open/) - }) - - it('rejects a turn/end that does not match the open turn', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) - .toThrow(/does not match open turn 1/) - }) - - it('rejects a step/start outside its declared turn', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) - }) - - it('rejects a step/end that does not match the open step', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => session.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/) - }) - - it('rejects an assistant/chunk outside an open step', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } })) - .toThrow(/open is turn 1\/step null/) - }) - - it('rejects a message event appended outside any open turn (turn-enclosure)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC). - expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .toThrow(/outside any open turn/) - expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .toThrow(/outside any open turn/) - }) - - it('rejects steering and plugin-added events appended outside any open turn', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // steering/message is turn-scoped: outside a turn it would land past the - // commit boundary and be dropped on resume (the turn-enclosure RFC). - expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .toThrow(/outside any open turn/) - // A PLUGIN-added (merge-extensible) event type is caught by the default too. - // Cast through `any`: 'compaction/marker' is not in SessionEventType (it's - // merge-extensible), so the typed append() won't accept it. The test verifies - // the runtime default-branch turn-enclosure check. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return - expect(() => (session.append as any)('compaction/marker', { foo: 'bar' })) - .toThrow(/outside any open turn/) - }) - - it('accepts message events once a turn is open', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .not.toThrow() - }) - - it('rejects a tool/result with no prior tool/call', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }, { surfaceOp: 'append' })) - .toThrow(/no prior tool\/call/) - }) - - it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' }, - ] }, { surfaceOp: 'append' }) - session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('crashed'), - content: [{ type: 'text', text: 'interrupted' }], - isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) - }).not.toThrow() - }) - - it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) - }).not.toThrow() - }) - - it('holds seeded sessions to the contract on session/created', async () => { - const { ctx } = await setup() - // A seq-contiguous, serializable seed (so it passes Session's constructor - // validation) that nonetheless violates turn nesting — a second turn/start - // while the first turn is still open — must be rejected by the invariants - // plugin when it replays the seed on session/created. - const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - ] - expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) - }) - - it('tracks turns per session independently', async () => { - const { ctx } = await setup() - const a = ctx.sessions.create(SessionId('a')) - const b = ctx.sessions.create(SessionId('b')) - a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // b is a fresh session — its own turn/start must not see a's open turn. - expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() - }) - - it('accepts multiple steps in a turn and consecutive turns', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('step/start', { turn: 1, step: 2 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 2 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('rejects a skipped turn number', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(() => session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) - .toThrow(/expected turn 2, got 3/) - }) - - it('rejects a skipped step number within a turn', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('step/end', { turn: 1, step: 1 }) - expect(() => session.append('step/start', { turn: 1, step: 3 })) - .toThrow(/expected step 2 in turn 1, got 3/) - }) - - it('rejects a turn/end while a step is still open', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) - .toThrow(/while step 1 is still open/) - }) - - it('rejects a step/start while a step is still open', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => session.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) - }) - - it('rejects a tool/result satisfying a call from a previous step', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - // step ends with the call unresolved — pendingCalls is cleared. - session.append('step/end', { turn: 1, step: 1 }) - session.append('step/start', { turn: 1, step: 2 }) - expect(() => session.append('tool/result', { turn: 1, step: 2, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })) - .toThrow(/no prior tool\/call in this step/) - }) - - it('rejects an assistant/message naming the wrong step', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' })) - .toThrow(/open is turn 1\/step 1/) - }) -}) - -describe('HMR state rebuild', () => { - it('rebuilds trace state for a session that exists at (re-)apply time', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const first = await ctx.plugin(Invariants) - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - await first.dispose() - - // Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log. - await ctx.plugin(Invariants) - expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) - .not.toThrow() - // Rebuild must not disable later violations. - expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) - .toThrow(/turn 1 is still open/) - }) -}) - -describe('session immutability', () => { - it('always freezes appended event data without the invariants plugin', () => { - const session = new Session(SessionId('appended')) - const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(Object.isFrozen(event)).toBe(true) - expect(Object.isFrozen(event.data)).toBe(true) - expect(Object.isFrozen(event.data.content)).toBe(true) - expect(Object.isFrozen(event.data.content[0])).toBe(true) - expect(Object.isFrozen(session.events)).toBe(true) - expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow() - }) - - it('always freezes seeded events without the invariants plugin', () => { - const seed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, - ] - const session = new Session(SessionId('seeded'), seed) - expect(Object.isFrozen(seed[0])).toBe(false) - expect(Object.isFrozen(session.events)).toBe(true) - expect(Object.isFrozen(session.events[0])).toBe(true) - expect(Object.isFrozen(session.events[0]?.data)).toBe(true) - expect(Object.isFrozen(session.events[1]?.data)).toBe(true) - }) - - it('snapshots and freezes descendants of a shallow-frozen caller value', () => { - const session = new Session(SessionId('shallow-frozen')) - const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] - const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) - const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] } - expect(Object.isFrozen(innerContent)).toBe(false) - expect(Object.isFrozen(logged.content)).toBe(true) - expect(Object.isFrozen(logged.content[0])).toBe(true) - innerContent[0]!.text = 'caller mutation' - expect(logged.content[0]!.text).toBe('inner') - expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow() - }) -}) - -describe('agent status invariants', () => { - it('accepts legal transitions: idle→running→idle and →disposed', async () => { - const { ctx } = await setup() - const agent = mockAgent('a1') - expect(() => { - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') - }).not.toThrow() - }) - - it('accepts running→disposed', async () => { - const { ctx } = await setup() - const agent = mockAgent('a2') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() - }) - - it('rejects a no-op transition', async () => { - const { ctx } = await setup() - const agent = mockAgent('a3') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/) - }) - - it('rejects leaving the terminal disposed state', async () => { - const { ctx } = await setup() - const agent = mockAgent('a4') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) - }) - - it('tracks status per agent independently', async () => { - const { ctx } = await setup() - const a = mockAgent('a5') - const b = mockAgent('b5') - ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') - // b's first observation is independent of a. - expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() - }) -}) - -describe('HMR safety', () => { - it('removes all listeners when the plugin fiber is disposed', async () => { - const { ctx, fiber } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - - await fiber.dispose() - - // After disposal the plugin's assertions are gone, so an event that would - // violate the open-turn rule passes. Session still owns immutability. - const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(Object.isFrozen(event)).toBe(true) - // A no-op status transition no longer throws either. - const agent = mockAgent('hmr') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).not.toThrow() - }) - - it('InvariantError carries a stable code', () => { - const err = new InvariantError('seq must strictly increase') - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe('InvariantError') - expect(err.code).toBe('INVARIANT') - expect(err.message).toBe('invariant violated: seq must strictly increase') - }) - - it('does not leak listeners across dispose', async () => { - const { ctx, fiber } = await setup() - await fiber.dispose() - const spy = vi.fn() - ctx.on('session/event', spy) - const session = ctx.sessions.create() - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // The spy proves events still flow after plugin disposal. Session, not the - // disposed listener, freezes the accepted record. - expect(spy).toHaveBeenCalledOnce() - expect(Object.isFrozen(session.events[0])).toBe(true) - }) -}) - -describe('surface contract under the invariants composition', () => { - it('accepts well-formed surface metadata', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // Events must be turn-enclosed and step-scoped events need an open step. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => { - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).not.toThrow() - }) - - it('accepts replace surface op', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) - // no throw — well-formed replace op - }) - - it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).not.toThrow() - expect(() => { - session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).toThrow(/must not be empty except on assistant\/message/) - }) - - it('rejects duplicate sourceEventSeqs', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1, 1] }) - }).toThrow(/must not contain duplicates/) - }) - - it('rejects sourceEventSeqs referencing the event itself (self-reference)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // seq 0 - // The next event is seq 1. Referencing its own seq fails on "must reference - // earlier events" (the check order is: earlier first, then unknown). - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).toThrow(/must reference earlier/) - }) - - it('accepts sourceEventSeqs referencing a valid earlier event', async () => { - // Session seqs are contiguous, so every non-negative ref below the current - // seq necessarily names an existing earlier event. - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - // seqs so far: 0, 1. The next event at seq 2 references seq 1 → valid. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).not.toThrow() - }) - - it('rejects sourceEventSeqs referencing a far-future seq', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [99] }) - }).toThrow(/must reference earlier/) - }) - - it('rejects a replace whose start is positioned after its end on the surface', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Reversed range: start seq 3 is at a later surface position than end seq 2. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) - }).toThrow(/is after end seq 2/) - }) - - it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace shadows surface nodes [2, 3] but records provenance for only [2]. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] }) - }).toThrow(/must include every shadowed surface node; missing 3/) - }) - - it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] }) - }).not.toThrow() - }) - - it('rejects a replace naming a start seq that is not on the surface', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // seq 1 (step/start) is a real earlier event but never entered the surface. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - }).toThrow(/start seq 1 not found in surface/) - }) - - it('rejects a replace naming an end seq that is not on the surface', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // start (2) is on the surface but end (99) never entered it. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] }) - }).toThrow(/end seq 99 not found in surface/) - }) - - it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4 - // precedes seq 3 in surface order even though 4 > 3 numerically. - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 - // A replace with start=3, end=4 passes the seq check (3 <= 4) but is - // reversed positionally (3 is at pos 1, 4 is at pos 0). - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5 - }).toThrow(/is after end seq 4/) - }) - - it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the - // head seq (4) is numerically GREATER than the tail seq (3): the surface is - // not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is - // valid positionally and must be accepted even though start seq > end seq. - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5 - }).not.toThrow() - }) - - it('rejects a replace that omits sourceEventSeqs entirely', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // A replace with no sourceEventSeqs records no provenance for the node it shadows. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }) - }).toThrow(/must include every shadowed surface node; missing 2/) - }) - - it('catches an incomplete-provenance replace on the load/seed path', async () => { - const { ctx } = await setup() - const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } }, - { type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, - { type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, - { type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] }, - ] - expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/) - }) - -}) - -describe('request-reconstruction cross-check (llm/stream)', () => { - /** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */ - async function requestSetup() { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('req-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const boundary = session.deriveMessages() - session.append('step/start', { turn: 1, step: 1 }) - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - return { ctx, session, boundary } - } - - /** Dispatch the llm/stream waterfall with a stub core, collecting the check's verdict. */ - function dispatch(ctx: Context, options: unknown): void { - // The invariants listener runs synchronously at dispatch time (its checks - // precede next()); the stub core just yields nothing. - void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) - } - - it('passes a frozen request that equals the boundary derivation + the folded header', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('is boundary-correct: content logged after step/start is legitimately absent from this request', async () => { - const { ctx, session, boundary } = await requestSetup() - // An agent/request-window inject: lands in the log after the boundary, - // belongs to the NEXT request. A current-surface comparison would - // false-fire here; the seq-bounded rebuild must not. - session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => { - const { ctx, session, boundary } = await requestSetup() - const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - // The prefixed request matches the fold… - const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id }) - expect(() => { dispatch(ctx, prefixed) }).not.toThrow() - // …a request that DROPPED the logged prefix diverges… - const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id }) - expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/) - // …and so does one that misplaced it (prefix sent after the history). - const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id }) - expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/) - }) - - it('rejects a frozen request whose messages diverge from the boundary derivation', async () => { - const { ctx, session, boundary } = await requestSetup() - const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] - const options = Object.freeze({ model: 'm', messages: Object.freeze(messages), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the boundary derivation/) - }) - - it('rejects a frozen request whose fields diverge from the folded header', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the folded request header/) - }) - - it('rejects a loop-built request with no header event or no step/start in its log', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('req-bare')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) - expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) - - session.append('step/start', { turn: 1, step: 1 }) - expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) - }) - - it('rejects a frozen request carrying an unfrozen messages array', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/frozen messages array/) - }) - - it('skips hand-built (unfrozen) requests — compaction summarize is out of scope', async () => { - const { ctx, session } = await requestSetup() - // Unfrozen envelope + arbitrary messages: a direct one-shot call. - const options = { model: 'summarizer', messages: [{ role: 'user', content: [{ type: 'text', text: 'summarize!' }] }], sessionId: session.id } - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('skips requests without a sessionId or with an unknown session', async () => { - const { ctx } = await requestSetup() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }).not.toThrow() - }) -}) - -describe('request cross-check ordering (prepend)', () => { - it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => { - // Replay short-circuits without next(), so the check prepends ahead of ordinary listeners; - // correctness still comes from its sequence-bounded rebuild, not listener timing. - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() - await ctx.plugin(Invariants) - - const session = ctx.sessions.create(SessionId('prepend-check')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - - const divergent = Object.freeze({ - model: 'm', - messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), - sessionId: session.id, - }) - expect(() => { - void ctx.waterfall('llm/stream', divergent as never, () => (async function* () {})() as never) - }).toThrow(/diverges from the boundary derivation/) - }) -}) - -describe('scoped-dispatch invariants', () => { - async function scopedCtx() { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(Invariants) - return ctx - } - - it('rejects a scoped-family dispatch without a carrier (teaching error)', async () => { - const ctx = await scopedCtx() - const agent = { id: 'a1' } as unknown as Agent - expect(() => { ctx.emit('agent/error', agent, 1, 0, new Error('x')) }) - .toThrow(/dispatched without a scope carrier/) - }) - - it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => { - const ctx = await scopedCtx() - // Real Session objects keep the synthetic Agent handles structurally valid. - const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent - const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent - // One dispatch per table row keeps every subject extractor covered: the - // matching carrier passes, the foreign-keyed one throws. - const rows: [string, unknown[]][] = [ - ['agent/created', [agent]], - ['agent/disposed', [agent]], - ['agent/status', [agent, 'idle']], - ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], - ['agent/session-start', [agent, 'startup']], - ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], - ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], - ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], - ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], - ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], - ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], - ['agent/turn-stop', [agent, 1]], - ['agent/error', [agent, 1, 0, new Error('x')]], - ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], - ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], - ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], - ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], - ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], - ] - for (const [event, args] of rows) { - const subject = agent - expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) }, - `${event} with matching carrier`).not.toThrow() - expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) }, - `${event} with foreign carrier`).toThrow(/DIFFERENT subject/) - } - }) - - it('rejects a carrier keyed to a different subject than the arguments name', async () => { - const ctx = await scopedCtx() - const agent = { id: 'a1' } as unknown as Agent - const other = { id: 'a2' } as unknown as Agent - expect(() => { ctx.emit(scopeTarget(agent, other), 'agent/error', agent, 1, 0, new Error('x')) }) - .toThrow(/keyed to a DIFFERENT subject/) - // The correct spelling passes. - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/error', agent, 1, 0, new Error('x')) }) - .not.toThrow() - }) - -}) diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts new file mode 100644 index 0000000000..afeae22fb7 --- /dev/null +++ b/packages/support/invariants/tests/service.spec.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context, Service } from 'cordis' +import InvariantService, { InvariantError, type Config } from '@deepseek-ai/dsh-invariants' + +declare module 'cordis' { + interface Context { + invariantProbe: InvariantProbeService + } + + interface Events { + 'invariants-test/ping'(): void + } +} + +class InvariantProbeService extends Service { + constructor(ctx: Context) { + super(ctx, 'invariantProbe') + } +} + +interface RuntimeRegistration extends PromiseLike<() => void> { + (): void | Promise +} + +interface InstalledRegistration { + dispose(): Promise +} + +function runtimeRegistration(registration: () => void): RuntimeRegistration { + return registration as RuntimeRegistration +} + +async function setup(config: Config = {}): Promise<{ ctx: Context; fiber: Awaited> }> { + const ctx = new Context() + const fiber = await ctx.plugin(InvariantService, config) + return { ctx, fiber } +} + +async function registerProbe( + ctx: Context, + packageName: string, + probe: () => void, +): Promise { + const registration = runtimeRegistration(ctx.invariants.register(packageName, (child) => { + child.on('invariants-test/ping', probe, { global: true }) + })) + await registration + return { + async dispose() { await registration() }, + } +} + +describe('InvariantService selection', () => { + it('applies defaults when constructed directly without schema normalization', async () => { + const ctx = new Context() + const service = new InvariantService(ctx) + const probe = vi.fn() + const registration = runtimeRegistration(service.register('@deepseek-ai/dsh-session', (child) => { + child.on('invariants-test/ping', probe, { global: true }) + })) + await registration + ctx.emit('invariants-test/ping') + expect(probe).toHaveBeenCalledOnce() + await registration() + }) + + it('enables registrations by default and treats empty lists as admit-all and exclude-none', async () => { + for (const config of [{}, { package_allowlist: [], package_blocklist: [] }]) { + const { ctx } = await setup(config) + const probe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', probe) + ctx.emit('invariants-test/ping') + expect(probe).toHaveBeenCalledOnce() + } + }) + + it('disables every installer while still reserving package ownership', async () => { + const { ctx } = await setup({ enabled: false }) + const probe = vi.fn() + const registration = await registerProbe(ctx, '@deepseek-ai/dsh-session', probe) + expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + .toThrow(/already registered/) + ctx.emit('invariants-test/ping') + expect(probe).not.toHaveBeenCalled() + await registration.dispose() + }) + + it('uses unanchored, case-sensitive JavaScript regex sources', async () => { + const unanchored = await setup({ package_allowlist: ['session'] }) + const unanchoredProbe = vi.fn() + await registerProbe(unanchored.ctx, '@deepseek-ai/dsh-session-extra', unanchoredProbe) + unanchored.ctx.emit('invariants-test/ping') + expect(unanchoredProbe).toHaveBeenCalledOnce() + + const anchored = await setup({ package_allowlist: ['^@deepseek-ai/dsh-session$'] }) + const anchoredProbe = vi.fn() + await registerProbe(anchored.ctx, '@deepseek-ai/dsh-session-extra', anchoredProbe) + anchored.ctx.emit('invariants-test/ping') + expect(anchoredProbe).not.toHaveBeenCalled() + + const caseSensitive = await setup({ package_allowlist: ['Session'] }) + const caseProbe = vi.fn() + await registerProbe(caseSensitive.ctx, '@deepseek-ai/dsh-session', caseProbe) + caseSensitive.ctx.emit('invariants-test/ping') + expect(caseProbe).not.toHaveBeenCalled() + }) + + it('lets the blocklist override an allowlist match', async () => { + const { ctx } = await setup({ + package_allowlist: ['^@deepseek-ai/dsh-'], + package_blocklist: ['session'], + }) + const sessionProbe = vi.fn() + const agentProbe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', sessionProbe) + await registerProbe(ctx, '@deepseek-ai/dsh-agent', agentProbe) + ctx.emit('invariants-test/ping') + expect(sessionProbe).not.toHaveBeenCalled() + expect(agentProbe).toHaveBeenCalledOnce() + }) + + it('accepts zero-match patterns for packages registered later', async () => { + const { ctx } = await setup({ package_allowlist: ['^@later/invariants$'] }) + const now = vi.fn() + const later = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', now) + await registerProbe(ctx, '@later/invariants', later) + ctx.emit('invariants-test/ping') + expect(now).not.toHaveBeenCalled() + expect(later).toHaveBeenCalledOnce() + }) + + it('allows the same source in both lists and applies blocklist precedence', async () => { + const { ctx } = await setup({ package_allowlist: ['agent'], package_blocklist: ['agent'] }) + const probe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-agent', probe) + ctx.emit('invariants-test/ping') + expect(probe).not.toHaveBeenCalled() + }) +}) + +describe('InvariantService validation', () => { + it.each([ + [{ package_allowlist: [''] }, /non-blank/], + [{ package_allowlist: [' '] }, /non-blank/], + [{ package_allowlist: [' session'] }, /surrounding whitespace/], + [{ package_blocklist: ['session '] }, /surrounding whitespace/], + [{ package_allowlist: ['session', 'session'] }, /duplicate regex/], + [{ package_blocklist: ['agent', 'agent'] }, /duplicate regex/], + [{ package_allowlist: ['['] }, /invalid regex/], + [{ package_blocklist: ['('] }, /invalid regex/], + ])('rejects malformed filter config %#', async (config, message) => { + await expect((async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, config) + })()).rejects.toThrow(message) + }) + + it.each(['', ' ', ' package', 'pack age', 'package\n'])('rejects malformed package name %j', async (packageName) => { + const { ctx } = await setup() + expect(() => ctx.invariants.register(packageName, () => {})).toThrow(/packageName/) + }) +}) + +describe('InvariantService lifecycle', () => { + it('honors the installer dependency surface in its child fiber', async () => { + const { ctx } = await setup() + await ctx.plugin(InvariantProbeService) + let registration!: RuntimeRegistration + await ctx.plugin({ + inject: ['invariants', 'invariantProbe'], + apply(child: Context) { + const installer = Object.assign((installerCtx: Context) => { + expect(Object.keys(installerCtx.fiber.inject)).toContain('invariantProbe') + expect(Object.keys(installerCtx.fiber.store ?? {})).toContain('invariantProbe') + expect(installerCtx.invariantProbe).toBeInstanceOf(InvariantProbeService) + }, { inject: ['invariantProbe'] }) + expect(installer.inject).toEqual(['invariantProbe']) + registration = runtimeRegistration(child.invariants.register('@deepseek-ai/dsh-probe', installer)) + return Promise.resolve(registration) + }, + }) + await registration + }) + + it('attributes failures to the registering package with the stable code', async () => { + const { ctx } = await setup() + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child, fail) => { + child.on('invariants-test/ping', () => fail('seq must strictly increase'), { global: true }) + })) + await registration + let caught: unknown + try { + ctx.emit('invariants-test/ping') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect(caught).toMatchObject({ + name: 'InvariantError', + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session', + message: 'invariant violated by "@deepseek-ai/dsh-session": seq must strictly increase', + }) + }) + + it('disposes the child fiber completely and permits HMR re-registration', async () => { + const { ctx } = await setup() + const first = vi.fn() + const firstRegistration = await registerProbe(ctx, '@deepseek-ai/dsh-session', first) + ctx.emit('invariants-test/ping') + await firstRegistration.dispose() + ctx.emit('invariants-test/ping') + expect(first).toHaveBeenCalledOnce() + + const second = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', second) + ctx.emit('invariants-test/ping') + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + }) + + it('reserves ownership until asynchronous child disposal completes', async () => { + const { ctx } = await setup() + let finishDisposal!: () => void + const disposalBarrier = new Promise((resolve) => { finishDisposal = resolve }) + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => { + child.effect(() => async () => { await disposalBarrier }) + })) + await registration + + const disposing = registration() + expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + .toThrow(/already registered/) + finishDisposal() + await disposing + + const replacement = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + await replacement + await replacement() + }) + + it('rolls back listeners and ownership atomically when an installer fails', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => { + child.on('invariants-test/ping', leaked, { global: true }) + throw new Error('installer failed') + })) + await expect(Promise.resolve(failed)).rejects.toThrow('installer failed') + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + + const retry = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', retry) + ctx.emit('invariants-test/ping') + expect(retry).toHaveBeenCalledOnce() + }) + + it('releases a synchronous reservation if the service fiber is already inactive', async () => { + const { ctx, fiber } = await setup() + const service = ctx.invariants + await fiber.dispose() + expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) + }) +}) diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 6c5bc479b5..f0063ae28d 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -15,28 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/scope" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../ui/user-approval" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../subagent/subagent" + "path": "../../../vendor/schemastery" } ] } diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 3611c55e1f..0219940616 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -8,7 +8,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import ApprovalService from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -25,6 +28,13 @@ class SandboxedLocalExecutor extends LocalBashExecutor { } } +async function mountInvariants(ctx: BridgeHarness['ctx']): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function permissionOption(currentValue: string): object { return { id: 'permission', @@ -76,7 +86,7 @@ describe('acp bridge — session config options', () => { async function presetStack(options: { script?: NonNullable[0]>['script'] } = {}): Promise { const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} }) // Make an out-of-turn switch fail in this suite. - await harness.ctx.plugin(Invariants) + await mountInvariants(harness.ctx) await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) await harness.ctx.plugin(ApprovalService) await harness.ctx.plugin(PermissionService) diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 320a5322c7..e137d7f94f 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -3,7 +3,10 @@ import { Context } from 'cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' @@ -12,6 +15,13 @@ import WorkerWorkflowEngine from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + /** * The whole in-process stack, keyless, with the script in a REAL worker * thread: the engine drives the REAL spawn backend (with its @@ -24,7 +34,7 @@ async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7649345fc..43aae285c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -572,6 +575,9 @@ importers: packages/core/scope: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -581,6 +587,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -707,6 +716,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1745,31 +1757,11 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/invariants: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c10dd1396c..224f8540b8 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -116,6 +116,14 @@ const dshWorkerPackageFiles = [ 'src', ] as const +const dshInvariantPackageFiles = [ + 'lib/index.js', + 'lib/invariant.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-scripts': [ @@ -142,6 +150,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ] } if (manifest.bin) return dshBinPackageFiles + // Package-owned diagnostic companions are separately bundled optional + // entries; their source stays in the owner package without bloating root. + if (manifest.exports?.['./invariant']) return dshInvariantPackageFiles // A declared "./worker" subpath export sanctions the one extra runtime // bundle a worker-thread entry needs (and NodeNext/publint then validate // that subpath's targets like any other export). @@ -188,6 +199,16 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.exports?.['.']?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } + const invariantExport = manifest.exports?.['./invariant'] + if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') { + errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`) + } + if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') { + errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`) + } + if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) { + errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`) + } const expectedFiles = expectedDshPackageFiles(manifest) if (!sameStringList(manifest.files, expectedFiles)) { errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..f0d1a412dc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -155,6 +155,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', + InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', + InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0681153330..dbdc3102e3 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -100,9 +100,17 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, + { + key: 'invariants', + pkg: 'invariants', + title: 'Package-owned invariant registry', + mode: 'core', + consumers: ['session', 'agent', 'scope', 'agent-loop'], + note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.', + }, { key: 'sessionPersistence', pkg: 'session-persistence', @@ -158,7 +166,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index a7b93493ca..4aa6637934 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -1,6 +1,6 @@ /** - * Generate the dev-invariants scoped-event resolver map from the - * repository TypeScript Program. + * Generate dsh-scope's invariant resolver map from the repository TypeScript + * Program. * * A scoped event declares `this: Scoped`. Real `scopeTarget(base, key)` * calls establish the routing-key type for that base. The generator searches @@ -20,7 +20,7 @@ import { pointer, rawJsDoc } from './jsdoc.ts' import { TypeScriptProject } from './ts-project.ts' const root = resolve(import.meta.dirname, '..') -const OUT = 'packages/support/invariants/src/scoped-events.generated.ts' +const OUT = 'packages/core/scope/src/scoped-events.generated.ts' const SCOPE_DOC_MARKER = 'Scope-filtered dispatch' interface ScopeTargetContract { @@ -39,7 +39,6 @@ interface SubjectCandidate { interface ScopedEventResolver { event: string candidate: SubjectCandidate | null - ownerPackage: string } interface ScopeTag { @@ -54,7 +53,6 @@ class ScopedEventGenerator { private readonly scopeTargetDeclaration: ts.FunctionDeclaration private readonly scopedSymbol: ts.Symbol private readonly violations: string[] = [] - private readonly packageNames = new Map() constructor(private readonly project: TypeScriptProject) { this.checker = project.checker @@ -81,44 +79,25 @@ class ScopedEventGenerator { + this.violations.map(violation => ` - ${violation}`).join('\n'), ) } - const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))] - .sort() - .map(packageName => `import type {} from ${quote(packageName)}`) return [ '/**', - ' * Generated scoped-event routing-subject resolvers for dsh-invariants.', + ' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.', ' * Do not edit by hand; run `pnpm run gen-scoped-events`.', ' *', - ' * @module @deepseek-ai/dsh-invariants/scoped-events.generated', + ' * @module @deepseek-ai/dsh-scope/scoped-events.generated', ' */', '', - "import type { Events } from 'cordis'", - "import type { Scoped } from '@deepseek-ai/dsh-scope'", - ...ownerImports, - '', - 'type ScopedEventName = {', - ' [K in keyof Events]: ThisParameterType extends Scoped ? K : never', - '}[keyof Events]', - '', 'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown', '', - 'function adapt(', - ' resolver: (args: Parameters) => unknown,', - '): ScopedSubjectResolver {', - ' return args => resolver(args as Parameters)', - '}', - '', - 'const scopedSubjectResolvers = Object.freeze({', + 'const scopedSubjectResolvers: Readonly> = Object.freeze({', ...resolvers.map(({ event, candidate }) => { if (candidate === null) return ` '${event}': null,` const subject = candidate.property === undefined ? `args[${candidate.parameter}]` - : `args[${candidate.parameter}].${candidate.property}` - return ` '${event}': adapt<'${event}'>(args => ${subject}),` + : `(args[${candidate.parameter}] as Record)[${quote(candidate.property)}]` + return ` '${event}': args => ${subject},` }), - '} as const satisfies Readonly>)', - '', - 'const scopedSubjectResolverIndex: Readonly> = scopedSubjectResolvers', + '})', '', '/**', ' * Resolve the routing key named by one scoped event payload. A null', @@ -129,7 +108,7 @@ class ScopedEventGenerator { ' * or undefined when the event is not scope-filtered.', ' */', 'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {', - ' return scopedSubjectResolverIndex[event]', + ' return scopedSubjectResolvers[event]', '}', '', ].join('\n') @@ -186,7 +165,6 @@ class ScopedEventGenerator { const resolvers: ScopedEventResolver[] = [] for (const sourceFile of this.packageSources) { const rel = this.project.relativePath(sourceFile) - const ownerPackage = this.packageName(packageRootFor(rel)) const visit = (node: ts.Node): void => { if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) { for (const member of node.members) { @@ -232,7 +210,7 @@ class ScopedEventGenerator { + 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload', ) } - resolvers.push({ event, candidate: null, ownerPackage }) + resolvers.push({ event, candidate: null }) continue } if (tag.unsupported) { @@ -241,7 +219,7 @@ class ScopedEventGenerator { ) continue } - resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage }) + resolvers.push({ event, candidate: candidates[0] ?? null }) } } ts.forEachChild(node, visit) @@ -311,19 +289,6 @@ class ScopedEventGenerator { return dedupeCandidates(candidates) } - /** Read and cache one workspace package name. */ - private packageName(packageRoot: string): string { - const cached = this.packageNames.get(packageRoot) - if (cached) return cached - const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8')) - const name: unknown = typeof manifest === 'object' && manifest !== null - ? Reflect.get(manifest, 'name') - : undefined - if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`) - this.packageNames.set(packageRoot, name) - return name - } - /** Compare exact Program type identities after removing null and undefined. */ private typesEquivalent(left: ts.Type, right: ts.Type): boolean { const normalizedLeft = this.normalizedType(left) @@ -398,13 +363,6 @@ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandi }) } -/** Return the workspace package root owning one package source file. */ -function packageRootFor(relativePath: string): string { - const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath) - if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`) - return match[1] -} - /** Quote a generated property key as a single-quoted TypeScript string. */ function quote(value: string): string { return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` @@ -419,7 +377,7 @@ export function renderScopedEvents(projectRoot: string = root): string { return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render() } -/** Generate or freshness-check the fixed invariants source file. */ +/** Generate or freshness-check the fixed dsh-scope source file. */ function main(): void { const content = renderScopedEvents() const output = resolve(root, OUT) diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..17cc6e472d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,11 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], + "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], + "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], + "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], + "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 3a2b11db3c..d1d8f79f50 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -54,6 +54,10 @@ "text": "ctx.fs", "link": "/zh-CN/api/harness/fs" }, + { + "text": "ctx.invariants", + "link": "/zh-CN/api/harness/invariants" + }, { "text": "ctx.llm", "link": "/zh-CN/api/harness/llm" diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md new file mode 100644 index 0000000000..b87ea93aa7 --- /dev/null +++ b/website/zh-CN/api/harness/invariants.md @@ -0,0 +1,32 @@ + + +# ctx.invariants + +`InvariantService` — provided by `@deepseek-ai/dsh-invariants`. + +Package-owned invariant registry with global and regex-based selection. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L94) + +### ctx.invariants.register(packageName, installer) + +```ts website-api +/** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ +register(packageName: string, installer: InvariantInstaller): () => void +``` + +Register one package's invariant installer. The package name is reserved even when filtering disables its checks. Enabled installers run in a child fiber; failure disposes that fiber and releases the reservation. + +- `packageName` — full npm package name that owns the contribution. +- `installer` — synchronous listener installer for the child context. + +**Returns** an effect-scoped disposer for the registration. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L136) From 2c7822ddee5fc0ac6f7253de07f8e88a5a43a206 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:26:38 +0800 Subject: [PATCH 13/48] fix(docs): map exact source aliases in built checks --- scripts/doc-typecheck-paths.spec.ts | 17 +++++++++++++++++ scripts/doc-typecheck-paths.ts | 11 +++++++++++ scripts/doc-typecheck.ts | 8 ++------ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 scripts/doc-typecheck-paths.spec.ts create mode 100644 scripts/doc-typecheck-paths.ts diff --git a/scripts/doc-typecheck-paths.spec.ts b/scripts/doc-typecheck-paths.spec.ts new file mode 100644 index 0000000000..b0b3e3cfbf --- /dev/null +++ b/scripts/doc-typecheck-paths.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { builtDeclarationPath } from './doc-typecheck-paths.ts' + +describe('builtDeclarationPath', () => { + it('maps package source directories and exact entry files to built declarations', () => { + expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types') + expect(builtDeclarationPath('./packages/support/invariants/src/index.ts')) + .toBe('./packages/support/invariants/lib/types/index.d.ts') + expect(builtDeclarationPath('./packages/core/session/src/invariant.ts')) + .toBe('./packages/core/session/lib/types/invariant.d.ts') + }) + + it('rejects aliases without a supported source target', () => { + expect(() => builtDeclarationPath('./packages/support/invariants/source/index.ts')) + .toThrow('cannot map workspace source path') + }) +}) diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts new file mode 100644 index 0000000000..03b7edb118 --- /dev/null +++ b/scripts/doc-typecheck-paths.ts @@ -0,0 +1,11 @@ +/** Map one workspace source alias target to its declaration-build target. */ +export function builtDeclarationPath(candidate: string): string { + if (candidate.endsWith('/src')) { + return `${candidate.slice(0, -'/src'.length)}/lib/types` + } + const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate) + if (sourceFile?.[1] && sourceFile[2]) { + return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts` + } + throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`) +} diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 34af82b48b..bb9b203f9a 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process' import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import ts from 'typescript' +import { builtDeclarationPath } from './doc-typecheck-paths.ts' import { extractFences } from './md-fences.ts' const root = resolve(import.meta.dirname, '..') @@ -65,12 +66,7 @@ function builtTypeCompilerOptions(): ts.CompilerOptions { if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths') const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [ specifier, - candidates.map((candidate) => { - if (!candidate.endsWith('/src')) { - throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`) - } - return `${candidate.slice(0, -'/src'.length)}/lib/types` - }), + candidates.map(builtDeclarationPath), ])) const options: ts.CompilerOptions = { ...parsed.options, From 433670a75439804f1d6aedde3fbc60a7dc5ca3c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:13:50 +0800 Subject: [PATCH 14/48] feat(invariants): require package-owned companions --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 2 +- docs/module-graph.md | 251 ++++++++++------ ...06-11-dev-invariants-over-deep-readonly.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 18 +- ...7-19-package-owned-invariant-service.zh.md | 18 +- .../2026-06-16-typed-event-schemas.md | 6 +- docs/testing.md | 2 + knip.json | 21 +- package.json | 4 +- packages/AGENTS.md | 1 + packages/bash/bash-local/package.json | 7 + packages/bash/bash-local/src/invariant.ts | 30 ++ packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash-sandbox/package.json | 11 +- packages/bash/bash-sandbox/src/invariant.ts | 30 ++ packages/bash/bash-sandbox/tsconfig.json | 3 + packages/bash/bash/package.json | 7 + packages/bash/bash/src/invariant.ts | 30 ++ packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/package.json | 13 +- packages/bash/tool-bash/src/invariant.ts | 30 ++ packages/bash/tool-bash/tsconfig.json | 3 + .../code-runtime-worker/package.json | 7 + .../code-runtime-worker/src/invariant.ts | 30 ++ .../code-runtime-worker/tsconfig.json | 3 + .../code-runtime-worker/tsdown.config.ts | 2 +- .../code-runtime/code-runtime/package.json | 7 + .../code-runtime/src/invariant.ts | 30 ++ .../code-runtime/code-runtime/tsconfig.json | 3 + packages/compact/compact-basic/package.json | 6 + .../compact/compact-basic/src/invariant.ts | 30 ++ .../compact-basic/tests/compact-basic.spec.ts | 8 +- packages/compact/compact-basic/tsconfig.json | 35 ++- packages/compact/compact/package.json | 7 + packages/compact/compact/src/invariant.ts | 30 ++ packages/compact/compact/tsconfig.json | 3 + packages/context/time-context/package.json | 7 + .../context/time-context/src/invariant.ts | 30 ++ .../time-context/tests/time-context.spec.ts | 5 +- packages/context/time-context/tsconfig.json | 35 ++- .../context/workspace-context/package.json | 7 + .../workspace-context/src/invariant.ts | 30 ++ .../tests/workspace-context.spec.ts | 60 ++-- .../context/workspace-context/tsconfig.json | 3 + packages/cordis/tool-cordis/package.json | 15 +- packages/cordis/tool-cordis/src/invariant.ts | 30 ++ packages/cordis/tool-cordis/tsconfig.json | 3 + packages/core/agent-loop/src/invariant.ts | 6 +- packages/core/agent-loop/tests/resume.spec.ts | 4 +- packages/core/session/src/invariant.ts | 6 +- packages/core/session/tests/session.spec.ts | 23 +- packages/core/system-prompt/package.json | 7 + packages/core/system-prompt/src/invariant.ts | 30 ++ packages/core/system-prompt/tsconfig.json | 3 + packages/core/tools/package.json | 11 +- packages/core/tools/src/invariant.ts | 30 ++ packages/core/tools/tsconfig.json | 3 + packages/examples/acp-demo/package.json | 19 +- packages/examples/acp-demo/src/invariant.ts | 30 ++ packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/acp-demo/tsdown.config.ts | 2 +- .../examples/agent-spine-demo/package.json | 17 +- .../agent-spine-demo/src/invariant.ts | 30 ++ packages/examples/cli-demo/package.json | 7 + packages/examples/cli-demo/src/invariant.ts | 30 ++ packages/examples/cli-demo/tsconfig.json | 43 ++- packages/examples/cli-demo/tsdown.config.ts | 2 +- packages/examples/jsonrpc-demo/package.json | 7 + .../examples/jsonrpc-demo/src/invariant.ts | 30 ++ packages/examples/jsonrpc-demo/tsconfig.json | 3 + .../examples/jsonrpc-demo/tsdown.config.ts | 2 +- packages/examples/stdio-demo/package.json | 25 +- packages/examples/stdio-demo/src/invariant.ts | 30 ++ packages/examples/stdio-demo/tsconfig.json | 3 + packages/examples/stdio-demo/tsdown.config.ts | 2 +- packages/fs/fs-local/package.json | 7 + packages/fs/fs-local/src/invariant.ts | 30 ++ packages/fs/fs-local/tsconfig.json | 23 +- packages/fs/fs-policy/package.json | 7 + packages/fs/fs-policy/src/invariant.ts | 30 ++ packages/fs/fs-policy/tsconfig.json | 19 +- packages/fs/fs/package.json | 7 + packages/fs/fs/src/invariant.ts | 30 ++ packages/fs/fs/tsconfig.json | 19 +- packages/fs/tool-fs-search/package.json | 7 + packages/fs/tool-fs-search/src/invariant.ts | 30 ++ packages/fs/tool-fs-search/tsconfig.json | 43 ++- packages/fs/tool-fs/package.json | 9 +- packages/fs/tool-fs/src/invariant.ts | 30 ++ packages/fs/tool-fs/tsconfig.json | 35 ++- packages/guard/repeat-tool-guard/package.json | 7 + .../guard/repeat-tool-guard/src/invariant.ts | 30 ++ .../guard/repeat-tool-guard/tsconfig.json | 3 + packages/hooks/hook-protocol/package.json | 7 + packages/hooks/hook-protocol/src/invariant.ts | 30 ++ packages/hooks/hook-protocol/tsconfig.json | 3 + packages/hooks/hooks-claude/package.json | 7 + packages/hooks/hooks-claude/src/invariant.ts | 30 ++ .../hooks/hooks-claude/tests/bridge.spec.ts | 13 +- .../hooks-claude/tests/coverage-cases.ts | 15 +- packages/hooks/hooks-claude/tsconfig.json | 3 + packages/hooks/hooks-codex/package.json | 7 + packages/hooks/hooks-codex/src/invariant.ts | 30 ++ packages/hooks/hooks-codex/tsconfig.json | 3 + packages/llm/llm-deepseek/package.json | 7 + packages/llm/llm-deepseek/src/invariant.ts | 30 ++ packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/package.json | 7 + packages/llm/llm-pi-ai/src/invariant.ts | 30 ++ packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/package.json | 7 + packages/llm/llm/src/invariant.ts | 30 ++ packages/llm/llm/tsconfig.json | 3 + packages/llm/token-meter/package.json | 7 + packages/llm/token-meter/src/invariant.ts | 30 ++ .../llm/token-meter/tests/token-meter.spec.ts | 13 +- packages/llm/token-meter/tsconfig.json | 3 + packages/mcp/mcp-client/package.json | 11 +- packages/mcp/mcp-client/src/invariant.ts | 30 ++ packages/mcp/mcp-client/tsconfig.json | 23 +- packages/sandbox/sandbox-local/package.json | 7 + .../sandbox/sandbox-local/src/invariant.ts | 30 ++ packages/sandbox/sandbox-local/tsconfig.json | 3 + packages/sandbox/sandbox/package.json | 7 + packages/sandbox/sandbox/src/invariant.ts | 30 ++ packages/sandbox/sandbox/tsconfig.json | 3 + packages/sdk/create-sdk/package.json | 7 + packages/sdk/create-sdk/src/invariant.ts | 30 ++ packages/sdk/create-sdk/tsconfig.json | 11 +- packages/sdk/create-sdk/tsdown.config.ts | 2 +- packages/sdk/helper/package.json | 7 + packages/sdk/helper/src/invariant.ts | 30 ++ packages/sdk/helper/tsconfig.json | 39 ++- packages/sdk/helper/tsdown.config.ts | 2 +- packages/sdk/scripts/package.json | 15 +- packages/sdk/scripts/src/invariant.ts | 30 ++ packages/sdk/scripts/tsconfig.json | 15 +- packages/sdk/scripts/tsdown.config.ts | 4 + .../session-persistence-jsonl/package.json | 7 + .../src/invariant.ts | 30 ++ .../tests/jsonl.spec.ts | 29 +- .../session-persistence-jsonl/tsconfig.json | 3 + .../session-persistence-sqlite/package.json | 7 + .../src/invariant.ts | 30 ++ .../tests/sqlite.spec.ts | 32 +- .../session-persistence-sqlite/tsconfig.json | 3 + .../session-persistence/package.json | 8 + .../session-persistence/src/invariant.ts | 30 ++ .../tests/coordinator-contract.ts | 53 ++-- .../session-persistence/tsconfig.json | 3 + .../session-query/session-query/package.json | 7 + .../session-query/src/invariant.ts | 30 ++ .../session-query/tests/session-query.spec.ts | 32 +- .../session-query/tests/tracing.spec.ts | 59 ++-- .../session-query/session-query/tsconfig.json | 3 + packages/skill/skill-local/package.json | 7 + packages/skill/skill-local/src/invariant.ts | 30 ++ packages/skill/skill-local/tsconfig.json | 27 +- packages/skill/skill/package.json | 7 + packages/skill/skill/src/invariant.ts | 30 ++ packages/skill/skill/tsconfig.json | 15 +- packages/skill/tool-skill/package.json | 7 + packages/skill/tool-skill/src/invariant.ts | 30 ++ packages/skill/tool-skill/tsconfig.json | 35 ++- packages/spill/spill-local/package.json | 7 + packages/spill/spill-local/src/invariant.ts | 30 ++ packages/spill/spill-local/tsconfig.json | 19 +- packages/spill/spill-policy/package.json | 7 + packages/spill/spill-policy/src/invariant.ts | 30 ++ packages/spill/spill-policy/tsconfig.json | 35 ++- packages/spill/spill/package.json | 7 + packages/spill/spill/src/invariant.ts | 30 ++ packages/spill/spill/tsconfig.json | 23 +- packages/subagent/subagent-acp/package.json | 9 +- .../subagent/subagent-acp/src/invariant.ts | 30 ++ packages/subagent/subagent-acp/tsconfig.json | 3 + packages/subagent/subagent-fork/package.json | 8 +- .../subagent/subagent-fork/src/invariant.ts | 30 ++ packages/subagent/subagent-fork/tsconfig.json | 3 + .../subagent/subagent-inprocess/package.json | 6 + .../subagent-inprocess/src/invariant.ts | 30 ++ .../subagent/subagent-inprocess/tsconfig.json | 3 + packages/subagent/subagent-spawn/package.json | 8 +- .../subagent/subagent-spawn/src/invariant.ts | 30 ++ .../subagent/subagent-spawn/tsconfig.json | 3 + .../subagent/subagent-subprocess/package.json | 7 + .../subagent-subprocess/src/invariant.ts | 30 ++ .../subagent-subprocess/tsconfig.json | 6 +- packages/subagent/subagent/package.json | 7 + packages/subagent/subagent/src/invariant.ts | 30 ++ packages/subagent/subagent/tsconfig.json | 3 + packages/subagent/tool-subagent/package.json | 7 + .../subagent/tool-subagent/src/invariant.ts | 30 ++ packages/subagent/tool-subagent/tsconfig.json | 3 + packages/support/acp-snapshot/package.json | 7 + .../support/acp-snapshot/src/invariant.ts | 30 ++ packages/support/acp-snapshot/tsconfig.json | 7 +- .../support/agent-loop-testkit/package.json | 7 + .../agent-loop-testkit/src/invariant.ts | 30 ++ .../support/agent-loop-testkit/tsconfig.json | 3 + packages/support/invariants/README.md | 10 +- packages/support/invariants/package.json | 5 + packages/support/invariants/src/index.ts | 5 +- packages/support/invariants/src/invariant.ts | 30 ++ packages/support/llm-replay/package.json | 7 + packages/support/llm-replay/src/invariant.ts | 30 ++ packages/support/llm-replay/tsconfig.json | 3 + packages/support/loader-smoke/package.json | 7 + .../support/loader-smoke/src/invariant.ts | 30 ++ packages/support/loader-smoke/tsconfig.json | 6 +- packages/tasks/tasks/package.json | 9 +- packages/tasks/tasks/src/invariant.ts | 30 ++ packages/tasks/tasks/tsconfig.json | 3 + packages/tasks/tool-tasks/package.json | 7 + packages/tasks/tool-tasks/src/invariant.ts | 30 ++ packages/tasks/tool-tasks/tsconfig.json | 3 + packages/timeout/timeout-policy/package.json | 7 + .../timeout/timeout-policy/src/invariant.ts | 30 ++ packages/timeout/timeout-policy/tsconfig.json | 27 +- packages/todo/tool-todo/package.json | 7 + packages/todo/tool-todo/src/invariant.ts | 30 ++ packages/todo/tool-todo/tsconfig.json | 3 + packages/ui/acp/package.json | 6 + packages/ui/acp/src/invariant.ts | 30 ++ packages/ui/acp/tsconfig.json | 3 + packages/ui/app-boot/package.json | 7 + packages/ui/app-boot/src/invariant.ts | 30 ++ packages/ui/app-boot/tsconfig.json | 3 + packages/ui/jsonrpc/package.json | 7 + packages/ui/jsonrpc/src/invariant.ts | 30 ++ packages/ui/jsonrpc/tsconfig.json | 3 + packages/ui/permission/package.json | 7 + packages/ui/permission/src/invariant.ts | 30 ++ packages/ui/permission/tsconfig.json | 3 + packages/ui/stdio/package.json | 7 + packages/ui/stdio/src/invariant.ts | 30 ++ packages/ui/stdio/tests/stdio.spec.ts | 112 ++++--- packages/ui/stdio/tsconfig.json | 3 + packages/ui/tool-ask-user/package.json | 7 + packages/ui/tool-ask-user/src/invariant.ts | 30 ++ packages/ui/tool-ask-user/tsconfig.json | 3 + packages/ui/tui/package.json | 7 + packages/ui/tui/src/invariant.ts | 30 ++ packages/ui/tui/tests/harness.ts | 9 +- packages/ui/tui/tests/tui.snapshot.ts | 42 +-- packages/ui/tui/tests/tui.spec.ts | 138 +++++---- packages/ui/tui/tsconfig.json | 3 + packages/ui/user-approval/package.json | 7 + packages/ui/user-approval/src/invariant.ts | 30 ++ .../ui/user-approval/tests/approval.spec.ts | 4 +- packages/ui/user-approval/tsconfig.json | 3 + packages/ui/user-interaction/package.json | 7 + packages/ui/user-interaction/src/invariant.ts | 30 ++ packages/ui/user-interaction/tsconfig.json | 3 + packages/util/brand/package.json | 7 + packages/util/brand/src/invariant.ts | 30 ++ packages/util/brand/tsconfig.json | 6 +- packages/util/home/package.json | 7 + packages/util/home/src/invariant.ts | 30 ++ packages/util/home/tsconfig.json | 6 +- packages/util/paths/package.json | 7 + packages/util/paths/src/invariant.ts | 30 ++ packages/util/paths/tsconfig.json | 6 +- packages/util/retention/package.json | 7 + packages/util/retention/src/invariant.ts | 30 ++ packages/util/retention/tsconfig.json | 6 +- packages/util/timeout/package.json | 7 + packages/util/timeout/src/invariant.ts | 30 ++ packages/util/timeout/tsconfig.json | 6 +- packages/web/tool-web/package.json | 9 +- packages/web/tool-web/src/invariant.ts | 30 ++ packages/web/tool-web/tsconfig.json | 35 ++- packages/web/web-fetch-local/package.json | 7 + packages/web/web-fetch-local/src/invariant.ts | 30 ++ packages/web/web-fetch-local/tsconfig.json | 3 + packages/web/web-search-deepseek/package.json | 7 + .../web/web-search-deepseek/src/invariant.ts | 30 ++ .../web/web-search-deepseek/tsconfig.json | 3 + packages/web/web-search-exa/package.json | 7 + packages/web/web-search-exa/src/invariant.ts | 30 ++ packages/web/web-search-exa/tsconfig.json | 3 + .../web/web-search-perplexity/package.json | 7 + .../web-search-perplexity/src/invariant.ts | 30 ++ .../web/web-search-perplexity/tsconfig.json | 3 + packages/web/web/package.json | 7 + packages/web/web/src/invariant.ts | 30 ++ packages/web/web/tsconfig.json | 3 + packages/workflow/tool-workflow/package.json | 7 + .../workflow/tool-workflow/src/invariant.ts | 30 ++ packages/workflow/tool-workflow/tsconfig.json | 3 + .../workflow-workerthread/package.json | 6 + .../workflow-workerthread/src/invariant.ts | 30 ++ .../workflow-workerthread/tsconfig.json | 3 + .../workflow-workerthread/tsdown.config.ts | 2 +- packages/workflow/workflow/package.json | 7 + packages/workflow/workflow/src/invariant.ts | 30 ++ packages/workflow/workflow/tsconfig.json | 3 + pnpm-lock.yaml | 237 +++++++++++++++ scripts/check-workspace-constraints.ts | 62 +--- scripts/gen-package-invariants.ts | 44 +++ scripts/package-invariants.spec.ts | 104 +++++++ scripts/package-invariants.ts | 283 ++++++++++++++++++ scripts/run-gates.ts | 3 + scripts/test-invariants.spec.ts | 70 +++++ scripts/test-invariants.ts | 104 +++++++ tsdown.config.ts | 9 +- vitest.config.ts | 1 + vitest.e2e.config.ts | 1 + vitest.snapshot.config.ts | 1 + website/zh-CN/api/harness/invariants.md | 4 +- 316 files changed, 5348 insertions(+), 646 deletions(-) create mode 100644 packages/bash/bash-local/src/invariant.ts create mode 100644 packages/bash/bash-sandbox/src/invariant.ts create mode 100644 packages/bash/bash/src/invariant.ts create mode 100644 packages/bash/tool-bash/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime-worker/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime/src/invariant.ts create mode 100644 packages/compact/compact-basic/src/invariant.ts create mode 100644 packages/compact/compact/src/invariant.ts create mode 100644 packages/context/time-context/src/invariant.ts create mode 100644 packages/context/workspace-context/src/invariant.ts create mode 100644 packages/cordis/tool-cordis/src/invariant.ts create mode 100644 packages/core/system-prompt/src/invariant.ts create mode 100644 packages/core/tools/src/invariant.ts create mode 100644 packages/examples/acp-demo/src/invariant.ts create mode 100644 packages/examples/agent-spine-demo/src/invariant.ts create mode 100644 packages/examples/cli-demo/src/invariant.ts create mode 100644 packages/examples/jsonrpc-demo/src/invariant.ts create mode 100644 packages/examples/stdio-demo/src/invariant.ts create mode 100644 packages/fs/fs-local/src/invariant.ts create mode 100644 packages/fs/fs-policy/src/invariant.ts create mode 100644 packages/fs/fs/src/invariant.ts create mode 100644 packages/fs/tool-fs-search/src/invariant.ts create mode 100644 packages/fs/tool-fs/src/invariant.ts create mode 100644 packages/guard/repeat-tool-guard/src/invariant.ts create mode 100644 packages/hooks/hook-protocol/src/invariant.ts create mode 100644 packages/hooks/hooks-claude/src/invariant.ts create mode 100644 packages/hooks/hooks-codex/src/invariant.ts create mode 100644 packages/llm/llm-deepseek/src/invariant.ts create mode 100644 packages/llm/llm-pi-ai/src/invariant.ts create mode 100644 packages/llm/llm/src/invariant.ts create mode 100644 packages/llm/token-meter/src/invariant.ts create mode 100644 packages/mcp/mcp-client/src/invariant.ts create mode 100644 packages/sandbox/sandbox-local/src/invariant.ts create mode 100644 packages/sandbox/sandbox/src/invariant.ts create mode 100644 packages/sdk/create-sdk/src/invariant.ts create mode 100644 packages/sdk/helper/src/invariant.ts create mode 100644 packages/sdk/scripts/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence-sqlite/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence/src/invariant.ts create mode 100644 packages/session-query/session-query/src/invariant.ts create mode 100644 packages/skill/skill-local/src/invariant.ts create mode 100644 packages/skill/skill/src/invariant.ts create mode 100644 packages/skill/tool-skill/src/invariant.ts create mode 100644 packages/spill/spill-local/src/invariant.ts create mode 100644 packages/spill/spill-policy/src/invariant.ts create mode 100644 packages/spill/spill/src/invariant.ts create mode 100644 packages/subagent/subagent-acp/src/invariant.ts create mode 100644 packages/subagent/subagent-fork/src/invariant.ts create mode 100644 packages/subagent/subagent-inprocess/src/invariant.ts create mode 100644 packages/subagent/subagent-spawn/src/invariant.ts create mode 100644 packages/subagent/subagent-subprocess/src/invariant.ts create mode 100644 packages/subagent/subagent/src/invariant.ts create mode 100644 packages/subagent/tool-subagent/src/invariant.ts create mode 100644 packages/support/acp-snapshot/src/invariant.ts create mode 100644 packages/support/agent-loop-testkit/src/invariant.ts create mode 100644 packages/support/invariants/src/invariant.ts create mode 100644 packages/support/llm-replay/src/invariant.ts create mode 100644 packages/support/loader-smoke/src/invariant.ts create mode 100644 packages/tasks/tasks/src/invariant.ts create mode 100644 packages/tasks/tool-tasks/src/invariant.ts create mode 100644 packages/timeout/timeout-policy/src/invariant.ts create mode 100644 packages/todo/tool-todo/src/invariant.ts create mode 100644 packages/ui/acp/src/invariant.ts create mode 100644 packages/ui/app-boot/src/invariant.ts create mode 100644 packages/ui/jsonrpc/src/invariant.ts create mode 100644 packages/ui/permission/src/invariant.ts create mode 100644 packages/ui/stdio/src/invariant.ts create mode 100644 packages/ui/tool-ask-user/src/invariant.ts create mode 100644 packages/ui/tui/src/invariant.ts create mode 100644 packages/ui/user-approval/src/invariant.ts create mode 100644 packages/ui/user-interaction/src/invariant.ts create mode 100644 packages/util/brand/src/invariant.ts create mode 100644 packages/util/home/src/invariant.ts create mode 100644 packages/util/paths/src/invariant.ts create mode 100644 packages/util/retention/src/invariant.ts create mode 100644 packages/util/timeout/src/invariant.ts create mode 100644 packages/web/tool-web/src/invariant.ts create mode 100644 packages/web/web-fetch-local/src/invariant.ts create mode 100644 packages/web/web-search-deepseek/src/invariant.ts create mode 100644 packages/web/web-search-exa/src/invariant.ts create mode 100644 packages/web/web-search-perplexity/src/invariant.ts create mode 100644 packages/web/web/src/invariant.ts create mode 100644 packages/workflow/tool-workflow/src/invariant.ts create mode 100644 packages/workflow/workflow-workerthread/src/invariant.ts create mode 100644 packages/workflow/workflow/src/invariant.ts create mode 100644 scripts/gen-package-invariants.ts create mode 100644 scripts/package-invariants.spec.ts create mode 100644 scripts/package-invariants.ts create mode 100644 scripts/test-invariants.spec.ts create mode 100644 scripts/test-invariants.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 76663117b8..4ea9c7a300 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -396,7 +396,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:16`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5a868e1eff..ca5eb4232b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -495,7 +495,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:95`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2dc5a1c34e..fb99f5c15e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -502,7 +502,7 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-session/invariant` companion enforces it when selected through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events diff --git a/docs/module-graph.md b/docs/module-graph.md index af8504be18..07ef0f4da9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -150,23 +150,46 @@ flowchart TD pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] end - pkg_llm --> pkg_brand + pkg_brand --> pkg_invariants + pkg_home --> pkg_invariants + pkg_paths --> pkg_invariants + pkg_retention --> pkg_invariants + pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants + pkg_skill --> pkg_invariants + pkg_subagent_subprocess --> pkg_invariants + pkg_acp_snapshot --> pkg_invariants + pkg_loader_smoke --> pkg_invariants + pkg_app_boot --> pkg_invariants + pkg_code_runtime --> pkg_invariants + pkg_jsonrpc_demo --> pkg_invariants + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime + pkg_code_runtime_worker --> pkg_invariants pkg_helper --> pkg_brand + pkg_helper --> pkg_invariants pkg_scripts --> pkg_app_boot + pkg_scripts --> pkg_invariants + pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm + pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants pkg_fs --> pkg_llm + pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session pkg_agent --> pkg_brand @@ -175,64 +198,90 @@ flowchart TD pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_bash --> pkg_session pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants pkg_skill_local --> pkg_fs pkg_skill_local --> pkg_home + pkg_skill_local --> pkg_invariants pkg_skill_local --> pkg_skill + pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_web + pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand + pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session + pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_invariants pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter + pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session + pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence + pkg_session_persistence_sqlite --> pkg_invariants pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand + pkg_user_approval --> pkg_invariants pkg_user_approval --> pkg_llm pkg_user_approval --> pkg_scope pkg_user_approval --> pkg_session pkg_user_approval --> pkg_system_prompt pkg_user_interaction --> pkg_agent + pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand + pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand + pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime + pkg_tools --> pkg_invariants pkg_tools --> pkg_llm pkg_tools --> pkg_scope pkg_tools --> pkg_session @@ -240,8 +289,10 @@ flowchart TD pkg_tools --> pkg_user_approval pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_permission --> pkg_bash + pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval @@ -256,6 +307,7 @@ flowchart TD pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_home + pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox pkg_tool_bash --> pkg_session_persistence @@ -264,11 +316,13 @@ flowchart TD pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval pkg_tool_fs --> pkg_fs + pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_session pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs_search --> pkg_bash + pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_retention pkg_tool_fs_search --> pkg_session @@ -276,45 +330,55 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools pkg_tool_skill --> pkg_agent + pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_tools + pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_spill_policy --> pkg_invariants pkg_spill_policy --> pkg_llm pkg_spill_policy --> pkg_retention pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_tools + pkg_timeout_policy --> pkg_invariants pkg_timeout_policy --> pkg_llm pkg_timeout_policy --> pkg_timeout pkg_timeout_policy --> pkg_tools pkg_tool_todo --> pkg_agent + pkg_tool_todo --> pkg_invariants pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_tools + pkg_tool_cordis --> pkg_invariants pkg_tool_cordis --> pkg_scope pkg_tool_cordis --> pkg_tools pkg_hooks_codex --> pkg_agent pkg_hooks_codex --> pkg_hook_protocol + pkg_hooks_codex --> pkg_invariants pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash + pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox @@ -325,51 +389,62 @@ flowchart TD pkg_acp --> pkg_user_approval pkg_acp --> pkg_user_interaction pkg_tool_ask_user --> pkg_agent + pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction pkg_workspace_context --> pkg_agent pkg_workspace_context --> pkg_fs + pkg_workspace_context --> pkg_invariants pkg_workspace_context --> pkg_llm pkg_workspace_context --> pkg_paths pkg_workspace_context --> pkg_session pkg_workspace_context --> pkg_tools pkg_repeat_tool_guard --> pkg_agent + pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools + pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools pkg_tool_tasks --> pkg_agent + pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent + pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol + pkg_hooks_claude --> pkg_invariants pkg_hooks_claude --> pkg_llm pkg_hooks_claude --> pkg_session pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek pkg_jsonrpc --> pkg_scope @@ -377,11 +452,13 @@ flowchart TD pkg_jsonrpc --> pkg_subagent pkg_stdio --> pkg_agent pkg_stdio --> pkg_agent_loop + pkg_stdio --> pkg_invariants pkg_stdio --> pkg_llm pkg_stdio --> pkg_session pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_session pkg_tui --> pkg_tools @@ -404,20 +481,24 @@ flowchart TD pkg_agent_spine_demo --> pkg_workspace_context pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand + pkg_workflow_workerthread --> pkg_invariants pkg_workflow_workerthread --> pkg_llm pkg_workflow_workerthread --> pkg_session pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow pkg_subagent_fork --> pkg_agent + pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session pkg_subagent_fork --> pkg_subagent pkg_subagent_fork --> pkg_subagent_inprocess + pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_invariants pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -425,6 +506,7 @@ flowchart TD pkg_cli_demo --> pkg_agent pkg_cli_demo --> pkg_agent_spine_demo pkg_cli_demo --> pkg_app_boot + pkg_cli_demo --> pkg_invariants pkg_cli_demo --> pkg_llm pkg_cli_demo --> pkg_session pkg_cli_demo --> pkg_session_persistence_jsonl @@ -434,6 +516,7 @@ flowchart TD pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_invariants pkg_stdio_demo --> pkg_llm pkg_stdio_demo --> pkg_session pkg_stdio_demo --> pkg_session_persistence_jsonl @@ -447,92 +530,92 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | -| [`brand`](../packages/util/brand) | `util` | — | -| [`home`](../packages/util/home) | `util` | — | -| [`paths`](../packages/util/paths) | `util` | — | -| [`retention`](../packages/util/retention) | `util` | — | -| [`timeout`](../packages/util/timeout) | `util` | — | -| [`skill`](../packages/skill/skill) | `skill` | — | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`invariants`](../packages/support/invariants) | `support` | — | -| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | -| [`app-boot`](../packages/ui/app-boot) | `ui` | — | -| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | -| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | +| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`home`](../packages/util/home) | `util` | [`invariants`](../packages/support/invariants) | +| [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | +| [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | +| [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | -| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | +| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | +| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | +| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants) | +| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | -| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | -| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | -| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | +| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | +| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | -| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | -| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | +| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 9e272456fc..4b77930280 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -30,7 +30,7 @@ This guarantee belongs in `Session`, not in an optional listener, because every ### Package-owned invariant companions check relationships -`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Optional `./invariant` companions from `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` own the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). +`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Every package publishes a `./invariant` ownership companion; `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` currently add the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 81b1bd25df..4ee29e8fb8 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -21,7 +21,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di - An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. -- The optional `dsh-session/invariant` companion registers the dev check with `ctx.invariants`: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. +- The `dsh-session/invariant` companion registers the check with `ctx.invariants`: when selected, a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index e86d6b9610..62630152c9 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** In development, the optional `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index e7e43cc1eb..2be6be1c53 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -322,7 +322,7 @@ TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messa ### Runtime invariants cover cross-service facts -The optional `dsh-scope/invariant` companion verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. +The `dsh-scope/invariant` companion verifies, when selected, that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index ce8fabdc40..dfe64b7110 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: f147cd91134509e17df39d1acbb28ae8b521c2f8 -2026-07-19-package-owned-invariant-service.zh.md: a106b57137c36009d0d4b4d39f109b7abf857f42 +2026-07-19-package-owned-invariant-service.md: bf27bb4e951988bc124509b49dea9b5509f8e6f3 +2026-07-19-package-owned-invariant-service.zh.md: c9dfbb65bf4fd497f2f592f7741923dcef3e51fb diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md index f147cd9113..bf27bb4e95 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -10,13 +10,15 @@ Runtime invariant checks span session traces, agent state, scoped dispatch, and Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. +Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap. + ## Decision ### One registry service, package-owned contributions `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Packages expose diagnostics through optional `./invariant` companion plugins. Their root entrypoints do not import or register diagnostics implicitly, so loading a product package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -53,7 +55,7 @@ Registration setup is transactional. If an installer fails after registering lis The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. -### Shipped companions +### Stateful companions and exhaustive ownership | Companion entry | Registration name | Owned checks | |---|---|---| @@ -62,7 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -Each owner contains its source and focused tests. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. The service package retains only registry tests. +These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. + +`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. ### Scoped-event semantic map @@ -70,7 +74,7 @@ The generated scoped-event subject resolver lives in `dsh-scope`, beside the con ### Standard composition and SDK output -The standard agent spine mounts the service and all four companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. +The standard agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. @@ -80,18 +84,22 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. +Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. + ## Alternatives considered - **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. - **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. -- **Discover every `invariant.ts` file automatically.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. +- **Discover every `invariant.ts` file automatically at runtime.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. Build-time generation, verification, and the test host may enumerate the source tree because they validate repository completeness rather than composing a shipped deployment. - **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. +- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. +- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index a106b57137..c9dfbb65bf 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -10,13 +10,15 @@ Status: implemented 部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 +包所有权还必须覆盖完整。若没有机械化的仓库规则,新包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口。 + ## 决策 ### 一个注册服务,贡献归包所有 `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -各包通过可选的 `./invariant` 伴随插件公开诊断。根入口不会隐式导入或注册诊断,因此加载产品包本身不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -53,7 +55,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 -### 已提供的伴随插件 +### 有状态伴随插件与完整所有权 | 伴随入口 | 注册名 | 所属检查 | |---|---|---| @@ -62,7 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -每个所有者都保存自己的源码与聚焦测试。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。服务包只保留注册服务测试。 +这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 + +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 ### Scoped event 语义映射 @@ -70,7 +74,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 ### 标准组合与 SDK 输出 -标准 agent spine 会挂载服务和四个伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 +标准 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 @@ -80,18 +84,22 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 + ## 考虑过的替代方案 - **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 - **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 -- **自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。 +- **在运行时自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。构建期生成与校验以及测试 host 可以枚举源码树,因为它们验证的是仓库完整性,而不是组合已发布的部署。 - **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 +- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 +- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 5db00851ae..026273c088 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -28,7 +28,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **Six merge-extensible maps** (~370 LOC of core types): `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap` (in `dsh-llm`); `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap` (in `dsh-session`). - **~10 `declare module` augmentation sites** across `dsh-agent`, `dsh-agent-loop`, `dsh-bash`, `dsh-llm`, `dsh-session`, `dsh-session-persistence`, `dsh-system-prompt`, `dsh-tools` — each would move from declaration merging to a runtime `register()` call. - **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary. -- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the optional invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. +- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the package-owned invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. @@ -37,7 +37,7 @@ This is a repository-wide vocabulary redesign, not a persistence implementation ## Alternatives considered ### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary -Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships in dev but do not provide general runtime shape schemas. +Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships when enabled but do not provide general runtime shape schemas. - **Pros**: zero churn; plugin extension is a one-line `interface` augmentation with full type inference and no runtime registration ceremony; no new runtime dependency; the `defineTool` DSL and `assertNever` exhaustiveness keep working. - **Cons**: no runtime structural validation at the persistence boundary or at plugin seams; a malformed-but-JSON datum is caught late. @@ -72,4 +72,4 @@ Defer. If runtime validation is wanted at the durable boundary, **Option B** (sc - If a registry is adopted, is the library **schemastery** (already in the tree, already the config schema lib) or **Zod** (richer ecosystem, currently only transitive)? Adopting two schema libraries is a cost in itself. - Can a hybrid keep compile-time inference (so `defineTool` and plugin DX survive) while adding an *optional* runtime schema per variant, validated only at the persistence/wire boundary rather than on every in-process append? -- Does the `dsh-invariants` plugin already cover enough of the runtime-shape gap in dev that boundary validation is only needed for genuinely untrusted input (reload of an externally-modified log)? +- Does the `ctx.invariants` service already cover enough of the runtime-shape gap when enabled that boundary validation is only needed for genuinely untrusted input (reload of an externally-modified log)? diff --git a/docs/testing.md b/docs/testing.md index cd3b1aa9e8..97ec20e99f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,6 +9,8 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. + ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships a keyless smoke and — unless keyless-by-nature — a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/knip.json b/knip.json index 2c3465ab38..3735303d8f 100644 --- a/knip.json +++ b/knip.json @@ -35,23 +35,19 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/brand": { - "project": ["src/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts"] }, "packages/util/home": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/retention": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], @@ -59,8 +55,7 @@ }, "packages/support/loader-smoke": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/core/agent-loop": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], @@ -84,8 +79,7 @@ }, "packages/util/paths": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/web/web-search-exa": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], @@ -149,8 +143,7 @@ }, "packages/subagent/subagent-subprocess": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/fs/tool-fs": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], diff --git a/package.json b/package.json index e736e3f8d1..abf1e59178 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "gen-package-invariants": "tsx scripts/gen-package-invariants.ts", + "verify-package-invariants": "tsx scripts/gen-package-invariants.ts --check", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", @@ -77,7 +79,7 @@ "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e4b5ded6a3..1735c44978 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,6 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. +- **Every package owns an invariant companion.** Publish `./invariant`, register the manifest's exact npm name, and keep the generated ownership baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 381855b465..152c2db28b 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -31,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts new file mode 100644 index 0000000000..8af5f91b22 --- /dev/null +++ b/packages/bash/bash-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' + +/** Cordis companion plugin name. */ +export const name = 'bash-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 02448770f4..a55c76f00a 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b4f61abcbe..42ba34c6a2 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-bash-local": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -33,9 +39,10 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "node-addon-landlock-run": "0.0.0-test.0", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "node-addon-landlock-run": "0.0.0-test.0" } } diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts new file mode 100644 index 0000000000..e74190fa82 --- /dev/null +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-sandbox`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'bash-sandbox-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index 6dad98d54f..0b0e350ce9 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../bash/bash-local" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 0783995161..8bee74f632 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts new file mode 100644 index 0000000000..350f68bfc7 --- /dev/null +++ b/packages/bash/bash/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash' + +/** Cordis companion plugin name. */ +export const name = 'bash-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index b9ab094edb..0e228ada67 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 816be1ef88..ec17dbacdb 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,15 +28,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -41,10 +47,10 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -54,6 +60,7 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts new file mode 100644 index 0000000000..286089c7ff --- /dev/null +++ b/packages/bash/tool-bash/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-bash`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-bash/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' + +/** Cordis companion plugin name. */ +export const name = 'tool-bash-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 407e78ebd8..6854bb9f7e 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 77169f8eab..f9c0f6be4c 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -19,6 +23,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -27,6 +32,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts new file mode 100644 index 0000000000..41b3eab511 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime-worker`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-code-runtime-worker/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-worker-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/tsconfig.json b/packages/code-runtime/code-runtime-worker/tsconfig.json index af962eda4f..4a201c70e1 100644 --- a/packages/code-runtime/code-runtime-worker/tsconfig.json +++ b/packages/code-runtime/code-runtime-worker/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../code-runtime" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 6fee724195..1c40637722 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 5380d26ace..2d59302ec9 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts new file mode 100644 index 0000000000..6102927d77 --- /dev/null +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-code-runtime/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tsconfig.json b/packages/code-runtime/code-runtime/tsconfig.json index 754725418e..9966c8ca8a 100644 --- a/packages/code-runtime/code-runtime/tsconfig.json +++ b/packages/code-runtime/code-runtime/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index a0ee2b4036..44274238de 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts new file mode 100644 index 0000000000..f72c0e5898 --- /dev/null +++ b/packages/compact/compact-basic/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-compact-basic`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-compact-basic/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' + +/** Cordis companion plugin name. */ +export const name = 'compact-basic-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..31992793f0 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -10,7 +10,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' @@ -791,7 +791,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { - return ctx.serial('agent/post-step', owner, 1, 1, signal) + return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal) } function recover( @@ -802,7 +802,9 @@ describe('automatic listener and loader composition', () => { signal = SIGNAL, next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), ): Promise<{ action: 'fail' | 'retry' }> { - return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next) + return agentEvents(ctx, owner).waterfall( + 'agent/request-error', 1, 1, error, retryAttempt, signal, next, + ) } function overflow(message = 'provider overflow'): Error & { code: string } { diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 0103ad82a8..a009b9ecb8 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../llm/token-meter" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../compact" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../compact" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 985c42d3b2..135c688507 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts new file mode 100644 index 0000000000..f7a2cbffd1 --- /dev/null +++ b/packages/compact/compact/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-compact`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-compact/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-compact' + +/** Cordis companion plugin name. */ +export const name = 'compact-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact/tsconfig.json b/packages/compact/compact/tsconfig.json index 95245937ec..673ee51547 100644 --- a/packages/compact/compact/tsconfig.json +++ b/packages/compact/compact/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index c0d69a75cb..ffbc3a87ee 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,12 +31,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts new file mode 100644 index 0000000000..64fb98ac81 --- /dev/null +++ b/packages/context/time-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-time-context`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-time-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' + +/** Cordis companion plugin name. */ +export const name = 'time-context-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 06ae13818d..07c2fe54ae 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,8 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -83,7 +82,7 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await ctx.serial('agent/pre-step', agent, turn, step, signal) + await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal) } function textResponse(text: string): StreamChunk[] { diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index 7815242b55..491552def0 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/agent" }, - { "path": "../../core/system-prompt" }, - { "path": "../../core/agent" }, - { "path": "../../support/loader-smoke" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 7f704c838a..0c50b8cc17 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -39,6 +45,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts new file mode 100644 index 0000000000..f3e76225c0 --- /dev/null +++ b/packages/context/workspace-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workspace-context`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workspace-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' + +/** Cordis companion plugin name. */ +export const name = 'workspace-context-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 2ff2067cf5..bdeeabe810 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,8 +7,9 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -23,7 +24,12 @@ import type { import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type { + PostToolDecision, + ToolExecution, + ToolExecutionResult, + ToolExecutionToken, +} from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { discoverBaselineInstructionFiles, @@ -228,14 +234,31 @@ const composedPrefixes = new WeakMap() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { const empty: Message[] = [] - const prefix = await ctx.waterfall( - 'agent/session-prefix', agent, empty, AbortSignal.timeout(1000), + const prefix = await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, AbortSignal.timeout(1000), () => Promise.resolve(empty), ) composedPrefixes.set(agent, prefix) return prefix } +function toolEventCarrier(ctx: Context, exec: ToolExecution) { + return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent) +} + +function postExecute( + ctx: Context, + exec: ToolExecution, + result: Readonly, + next: () => Promise, +): Promise { + return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next) +} + +function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly): void { + ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result) +} + function derivedText(agent: Agent): string { return blocksText(composedPrefixes.get(agent)?.[0]?.content) } @@ -796,7 +819,7 @@ describe('workspace context request injection', () => { try { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const decision = await postExecute(ctx, stubToolExecution({ callId: CallId('no-fs-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -843,7 +866,7 @@ describe('workspace context request injection', () => { } // A later PostToolUse-style policy blocks this otherwise-successful read. - const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + const blocked = await postExecute(ctx, exec, result, async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked by policy' }], })) @@ -857,7 +880,7 @@ describe('workspace context request injection', () => { // The same read, when the downstream accepts, DOES surface the nested // instructions — proving the block branch above is what suppressed them, // and that the block did not consume the pending nested change. - const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + const accepted = await postExecute(ctx, exec, result, async () => ({ kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') @@ -1169,8 +1192,9 @@ describe('workspace context request injection', () => { const controller = new AbortController() const reason = new Error('cancel prefix') const empty: Message[] = [] - const pending = ctx.waterfall( - 'agent/session-prefix', stubAgent(root), empty, controller.signal, + const agent = stubAgent(root) + const pending = agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, controller.signal, () => Promise.resolve(empty), ) @@ -1660,7 +1684,7 @@ describe('dynamic nested workspace context injection', () => { signal: controller.signal, }) - const pending = ctx.waterfall('tools/post-execute', exec, { + const pending = postExecute(ctx, exec, { content: [{ type: 'text', text: 'ok' }], isError: false, }, () => Promise.resolve({ kind: 'accept' as const })) @@ -2387,12 +2411,12 @@ describe('dynamic nested workspace context injection', () => { isError: false, } - const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const failedStat = await postExecute(ctx, stubToolExecution({ callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) - const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const mismatchedStat = await postExecute(ctx, stubToolExecution({ callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) @@ -2626,19 +2650,19 @@ describe('dynamic nested workspace context injection', () => { const parent = Symbol('parent') as ToolExecutionToken const plainResult = { callId: CallId('plain'), content: [], isError: false } - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, }), plainResult) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) - ctx.emit('tools/result', { + emitToolResult(ctx, { ...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), token: parent, }, plainResult) @@ -2674,7 +2698,7 @@ describe('dynamic nested workspace context injection', () => { ] for (const item of cases) { - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const decision = await postExecute(ctx, stubToolExecution({ callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), name: item.name, arguments: item.arguments, diff --git a/packages/context/workspace-context/tsconfig.json b/packages/context/workspace-context/tsconfig.json index b4807ded65..b5aca1dfc8 100644 --- a/packages/context/workspace-context/tsconfig.json +++ b/packages/context/workspace-context/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../util/paths" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index e13de3e58b..7e7b75cab3 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,16 +36,17 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7", - "@cordisjs/plugin-timer": "workspace:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts new file mode 100644 index 0000000000..a3f375b69f --- /dev/null +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-cordis`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-cordis/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' + +/** Cordis companion plugin name. */ +export const name = 'tool-cordis-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/tsconfig.json b/packages/cordis/tool-cordis/tsconfig.json index cc9928d81f..4f10b49622 100644 --- a/packages/cordis/tool-cordis/tsconfig.json +++ b/packages/cordis/tool-cordis/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 41b9112a4e..ab6a885dc0 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -12,8 +12,8 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' /** Cordis companion plugin name. */ export const name = 'agent-loop-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants', 'sessions'] +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] /** Install the request-reconstruction contribution into its child registration fiber. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { @@ -66,7 +66,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /** * Register the agent-loop invariant companion. - * @param ctx - Cordis context carrying the invariant and session services. + * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ export const apply = (ctx: Context): Promise<() => void> => diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 74192dde90..be98eb8447 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -425,7 +425,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length }, }) - await ctx1.parallel('session/flush', forked) + await ctx1.sessions.flush(forked) await ctx1.fiber.dispose() // Lifecycle 2: resume it; the parentSession + seedLength header survives the @@ -482,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) - await ctx1.parallel('session/flush', a1.session) + await ctx1.sessions.flush(a1.session) await ctx1.fiber.dispose() // Lifecycle 2: resume; the injected context is still in the derived history. diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 2887d6f9f0..22b567183e 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -15,8 +15,8 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session' /** Cordis companion plugin name. */ export const name = 'session-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants', 'sessions'] +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] /** Per-session bookkeeping for relational log checks. */ interface SessionTrace { @@ -223,7 +223,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /** * Register the session invariant companion. - * @param ctx - Cordis context carrying the invariant and session services. + * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ export const apply = (ctx: Context): Promise<() => void> => diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 3b15157bde..a9addbf1cb 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -718,10 +718,11 @@ describe('SessionStore', () => { // may create an unrelated property with the old implementation's name, // but cannot suppress the durable event feed. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(events).toHaveLength(1) - expect(events[0]![0]).toBe(session) - expect(events[0]![1].type).toBe('user/message') + expect(events).toHaveLength(2) + expect(events[1]![0]).toBe(session) + expect(events[1]![1].type).toBe('user/message') expect(ctx.sessions.get(session.id)).toBe(session) expect(ctx.sessions.list()).toEqual([session]) @@ -733,6 +734,7 @@ describe('SessionStore', () => { const a = ctx.sessions.create(SessionId('fixed')) expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') + a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) @@ -958,8 +960,9 @@ describe('SessionStore', () => { ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(events).toHaveLength(1) + expect(events.at(-1)?.type).toBe('user/message') }) it('contains session/event observer failures after the append commit point', async () => { @@ -1043,6 +1046,8 @@ describe('SessionStore', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' }, @@ -1062,19 +1067,19 @@ describe('SessionStore', () => { step: 1, content: [{ type: 'text', text: 'replacement' }], }, { - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], + surfaceOp: { op: 'replace', start: 2, end: 2 }, + sourceEventSeqs: [2], })).toThrow('reject surface candidate') - expect(session.events).toHaveLength(1) - expect(surface.nodes).toEqual([0]) + expect(session.events).toHaveLength(3) + expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) session.append('user/message', { content: [{ type: 'text', text: 'next' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(surface.nodes).toEqual([0, 1]) + expect(surface.nodes).toEqual([2, 3]) expect(surface.replaceGeneration).toBe(0) }) diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 67161b79b4..6cf94f477e 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts new file mode 100644 index 0000000000..5117f93ce0 --- /dev/null +++ b/packages/core/system-prompt/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-system-prompt`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-system-prompt/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' + +/** Cordis companion plugin name. */ +export const name = 'system-prompt-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 91e7bf1ba4..c7de9a1b69 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 2fe3cbd448..535e60f60b 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -36,12 +42,13 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts new file mode 100644 index 0000000000..7c5743e1d4 --- /dev/null +++ b/packages/core/tools/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tools`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tools/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tools' + +/** Cordis companion plugin name. */ +export const name = 'tools-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index c94b270c8c..918112d7d0 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../ui/user-approval" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 28d057c0a0..6743c55a3e 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -32,28 +37,30 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", + "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts new file mode 100644 index 0000000000..d8f2ff17dc --- /dev/null +++ b/packages/examples/acp-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' + +/** Cordis companion plugin name. */ +export const name = 'acp-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index b0e537574a..2f6acd4a17 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/acp-demo/tsdown.config.ts b/packages/examples/acp-demo/tsdown.config.ts index 9dd130b30d..2fa93780be 100644 --- a/packages/examples/acp-demo/tsdown.config.ts +++ b/packages/examples/acp-demo/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * matching every package. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index cae0aab6d4..1cc6214599 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -25,12 +30,11 @@ "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -39,6 +43,7 @@ "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tool-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -46,12 +51,11 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -60,6 +64,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts new file mode 100644 index 0000000000..913b5ca2ab --- /dev/null +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-spine-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-agent-spine-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' + +/** Cordis companion plugin name. */ +export const name = 'agent-spine-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index 7b035e2524..1c00a32891 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -35,6 +40,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -49,6 +55,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts new file mode 100644 index 0000000000..5681362dcc --- /dev/null +++ b/packages/examples/cli-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-cli-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-cli-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cli-demo' + +/** Cordis companion plugin name. */ +export const name = 'cli-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index f25b1592ca..c7e3aed914 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -8,15 +8,38 @@ }, "include": ["src/**/*.ts"], "references": [ - { "path": "../../../vendor/schemastery" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../../core/system-prompt" }, - { "path": "../../core/tools" }, - { "path": "../agent-spine-demo" }, - { "path": "../../session-persistence/session-persistence-jsonl" }, - { "path": "../../ui/app-boot" } + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../agent-spine-demo" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../ui/app-boot" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/examples/cli-demo/tsdown.config.ts b/packages/examples/cli-demo/tsdown.config.ts index e5b164d46f..646855bea9 100644 --- a/packages/examples/cli-demo/tsdown.config.ts +++ b/packages/examples/cli-demo/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Builds the plugin and executable entries from declarations emitted by `tsc -b`. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 3b04fcc977..d103e3bd00 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -33,9 +38,11 @@ "@deepseek-ai/dsh-app-boot": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts new file mode 100644 index 0000000000..21f19faf0f --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-jsonrpc-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' + +/** Cordis companion plugin name. */ +export const name = 'jsonrpc-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/examples/jsonrpc-demo/tsconfig.json b/packages/examples/jsonrpc-demo/tsconfig.json index 83d6cf5aa2..aba279d405 100644 --- a/packages/examples/jsonrpc-demo/tsconfig.json +++ b/packages/examples/jsonrpc-demo/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../ui/app-boot" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/jsonrpc-demo/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts index aaa860edd0..a8864a84a9 100644 --- a/packages/examples/jsonrpc-demo/tsdown.config.ts +++ b/packages/examples/jsonrpc-demo/tsdown.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'tsdown' * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 94554e3f8e..69c45bb362 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -33,19 +38,20 @@ "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@cordisjs/plugin-logger-console": "^1.0.0", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", + "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, @@ -53,20 +59,21 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-stdio": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts new file mode 100644 index 0000000000..5d51c3cd9b --- /dev/null +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-stdio-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' + +/** Cordis companion plugin name. */ +export const name = 'stdio-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index fc6711ffb9..265ee5f35b 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/stdio-demo/tsdown.config.ts b/packages/examples/stdio-demo/tsdown.config.ts index 53797cdd79..7e907e8a18 100644 --- a/packages/examples/stdio-demo/tsdown.config.ts +++ b/packages/examples/stdio-demo/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * matching every package. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index dd80cb4d9c..75a0400afb 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -30,6 +36,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts new file mode 100644 index 0000000000..0c89f2299b --- /dev/null +++ b/packages/fs/fs-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' + +/** Cordis companion plugin name. */ +export const name = 'fs-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json index 0808fd29ca..b249913d43 100644 --- a/packages/fs/fs-local/tsconfig.json +++ b/packages/fs/fs-local/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../fs" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../fs" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index e27302e4a6..e74852ef7d 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,10 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts new file mode 100644 index 0000000000..11f155d2ec --- /dev/null +++ b/packages/fs/fs-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' + +/** Cordis companion plugin name. */ +export const name = 'fs-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/tsconfig.json b/packages/fs/fs-policy/tsconfig.json index fcc1307a36..3f22545107 100644 --- a/packages/fs/fs-policy/tsconfig.json +++ b/packages/fs/fs-policy/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../llm/llm" }, - { "path": "../fs" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../fs" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index f1efde152a..3f15e0762a 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts new file mode 100644 index 0000000000..55895b4b24 --- /dev/null +++ b/packages/fs/fs/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs' + +/** Cordis companion plugin name. */ +export const name = 'fs-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index a352aea65a..5fb6fec5a3 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../llm/llm" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 002d54569a..45de8f5e4a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -38,6 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts new file mode 100644 index 0000000000..f0055b611a --- /dev/null +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs-search`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-fs-search/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' + +/** Cordis companion plugin name. */ +export const name = 'tool-fs-search-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json index 9241aca15b..ad0c703117 100644 --- a/packages/fs/tool-fs-search/tsconfig.json +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -6,15 +6,38 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/retention" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../../bash/bash" }, - { "path": "../../spill/spill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/retention" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../spill/spill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f9ef3136bf..51586836c9 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -37,9 +43,10 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts new file mode 100644 index 0000000000..7a683d978f --- /dev/null +++ b/packages/fs/tool-fs/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-fs/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' + +/** Cordis companion plugin name. */ +export const name = 'tool-fs-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index f0133b1d2b..43c91b2dd6 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../fs" }, - { "path": "../fs-policy" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../fs" + }, + { + "path": "../fs-policy" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 92d49c548f..c892ca99e4 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -33,6 +39,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts new file mode 100644 index 0000000000..05c4df1a66 --- /dev/null +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-repeat-tool-guard`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-repeat-tool-guard/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' + +/** Cordis companion plugin name. */ +export const name = 'repeat-tool-guard-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/tsconfig.json b/packages/guard/repeat-tool-guard/tsconfig.json index 66439bcd5f..9ca11b7119 100644 --- a/packages/guard/repeat-tool-guard/tsconfig.json +++ b/packages/guard/repeat-tool-guard/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 201c744219..f357278db3 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts new file mode 100644 index 0000000000..9893493644 --- /dev/null +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hook-protocol`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hook-protocol/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' + +/** Cordis companion plugin name. */ +export const name = 'hook-protocol-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/tsconfig.json b/packages/hooks/hook-protocol/tsconfig.json index dc4f8d9e16..220748cb0f 100644 --- a/packages/hooks/hook-protocol/tsconfig.json +++ b/packages/hooks/hook-protocol/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index c6870a6471..34696ab625 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -41,6 +47,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts new file mode 100644 index 0000000000..5c6002f7f5 --- /dev/null +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-claude`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hooks-claude/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' + +/** Cordis companion plugin name. */ +export const name = 'hooks-claude-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 65dd29d146..953b4befd0 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,7 +10,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -25,6 +26,10 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function subagentCarrier(ctx: Context) { + return scopeTarget(ctx as unknown as SubagentService, undefined) +} + /** Write a hooks.json + named executable scripts into a fresh temp dir. */ function writeConfig(hooks: unknown, scripts: Record = {}): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) @@ -290,8 +295,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) - ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -326,7 +331,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index ec7d18b4c4..4b8036f3a5 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,7 +10,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -20,6 +21,10 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function subagentCarrier(ctx: Context) { + return scopeTarget(ctx as unknown as SubagentService, undefined) +} + function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } function sh(d: string, name: string, body: string): string { const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p @@ -233,7 +238,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const injected: string[] = [] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -249,7 +254,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -293,7 +298,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -693,7 +698,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json index 07c88610f9..445d0f68b1 100644 --- a/packages/hooks/hooks-claude/tsconfig.json +++ b/packages/hooks/hooks-claude/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 8c8686c539..5d583baafe 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -40,6 +46,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts new file mode 100644 index 0000000000..1b8f03a057 --- /dev/null +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-codex`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hooks-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' + +/** Cordis companion plugin name. */ +export const name = 'hooks-codex-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index ae3c91e9dd..3bd9bd91e5 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 1461ad0f44..af8ca45085 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts new file mode 100644 index 0000000000..c3e6b5e153 --- /dev/null +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-deepseek`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-deepseek/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' + +/** Cordis companion plugin name. */ +export const name = 'llm-deepseek-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c922467deb..6901441490 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts new file mode 100644 index 0000000000..4ada7a1a51 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-pi-ai`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-pi-ai/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' + +/** Cordis companion plugin name. */ +export const name = 'llm-pi-ai-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index ab11c8574f..9ef3e3323e 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,10 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts new file mode 100644 index 0000000000..a8a56ce245 --- /dev/null +++ b/packages/llm/llm/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm' + +/** Cordis companion plugin name. */ +export const name = 'llm-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 342f636170..5bc7a9fcf5 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 13fa4e3cdc..dadd5e8f8d 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts new file mode 100644 index 0000000000..00ceb567be --- /dev/null +++ b/packages/llm/token-meter/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-token-meter`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-token-meter/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' + +/** Cordis companion plugin name. */ +export const name = 'token-meter-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e7ddca6696..d49c96fdb0 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -631,19 +631,24 @@ describe('malformed replay and listener lifecycle', () => { }) const firstFiber = await ctx.plugin(TokenMeterService) activeMeter = ctx.tokenMeter - const session = ctx.sessions.create(SessionId('listener-order')) + const session = ctx.sessions.create(SessionId('listener-order'), { seed: [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }] }) activeMeter.measure(session) session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(revisions).toEqual([1]) - expect(activeMeter.measure(session).logRevision).toBe(1) + expect(revisions).toEqual([2]) + expect(activeMeter.measure(session).logRevision).toBe(2) await firstFiber.dispose() const secondFiber = await ctx.plugin(TokenMeterService) activeMeter = ctx.tokenMeter - expect(activeMeter.measure(session).logRevision).toBe(1) + expect(activeMeter.measure(session).logRevision).toBe(2) await secondFiber.dispose() }) }) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 5e1604e02f..481fad6e15 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 6b8f145108..cb3b51e1aa 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -11,19 +11,25 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -31,8 +37,9 @@ "schemastery": "^3.18.0" }, "devDependencies": { - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", "cordis": "^4.0.0-rc.7", diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts new file mode 100644 index 0000000000..d9e75e9955 --- /dev/null +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-mcp-client`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-mcp-client/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' + +/** Cordis companion plugin name. */ +export const name = 'mcp-client-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index e9c9266415..668ee2c3cb 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 9dd90a3e9a..6750b92577 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts new file mode 100644 index 0000000000..3582962f94 --- /dev/null +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-sandbox-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index c756b6af69..608f0e9568 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../sandbox" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 50c5b443ba..84266499b3 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -11,21 +11,28 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts new file mode 100644 index 0000000000..b220f11717 --- /dev/null +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index 9f687793d7..af4de1c016 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/sdk/create-sdk/package.json b/packages/sdk/create-sdk/package.json index e23415f8dc..27102154fc 100644 --- a/packages/sdk/create-sdk/package.json +++ b/packages/sdk/create-sdk/package.json @@ -13,10 +13,15 @@ ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" } }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/assets", "lib/types/**/*.d.ts", @@ -29,9 +34,11 @@ "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts new file mode 100644 index 0000000000..87a697e438 --- /dev/null +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/create-sdk`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/create-sdk/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/create-sdk' + +/** Cordis companion plugin name. */ +export const name = 'create-sdk-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/create-sdk/tsconfig.json b/packages/sdk/create-sdk/tsconfig.json index f77f711880..e2ed951fa4 100644 --- a/packages/sdk/create-sdk/tsconfig.json +++ b/packages/sdk/create-sdk/tsconfig.json @@ -6,7 +6,14 @@ }, "include": ["src"], "references": [ - { "path": "../helper" }, - { "path": "../../../vendor/cordis" } + { + "path": "../helper" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/create-sdk/tsdown.config.ts b/packages/sdk/create-sdk/tsdown.config.ts index cdf9ef6d4f..08d522590e 100644 --- a/packages/sdk/create-sdk/tsdown.config.ts +++ b/packages/sdk/create-sdk/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Bundle the library and create bin, then mirror package-owned terminal templates. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 8d95dafe66..1d9c6a2be2 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -10,10 +10,15 @@ ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" } }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/assets", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -29,12 +34,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts new file mode 100644 index 0000000000..63a6fc2055 --- /dev/null +++ b/packages/sdk/helper/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-helper`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-helper/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-helper' + +/** Cordis companion plugin name. */ +export const name = 'helper-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index e4a8604575..18e79898c7 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -6,14 +6,35 @@ }, "include": ["src"], "references": [ - { "path": "../../util/brand" }, - { "path": "../../compact/compact-basic" }, - { "path": "../../hooks/hooks-claude" }, - { "path": "../../hooks/hooks-codex" }, - { "path": "../../session-persistence/session-persistence-jsonl" }, - { "path": "../../session-persistence/session-persistence-sqlite" }, - { "path": "../../subagent/tool-subagent" }, - { "path": "../../web/tool-web" }, - { "path": "../../../vendor/cordis" } + { + "path": "../../util/brand" + }, + { + "path": "../../compact/compact-basic" + }, + { + "path": "../../hooks/hooks-claude" + }, + { + "path": "../../hooks/hooks-codex" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../session-persistence/session-persistence-sqlite" + }, + { + "path": "../../subagent/tool-subagent" + }, + { + "path": "../../web/tool-web" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/helper/tsdown.config.ts b/packages/sdk/helper/tsdown.config.ts index cb8b9fafef..b8ba9cb652 100644 --- a/packages/sdk/helper/tsdown.config.ts +++ b/packages/sdk/helper/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Bundle helper runtime and mirror template assets beside the bundle. */ export default defineConfig({ - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index ba441a528c..3f607f7849 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./dev/tsdown-config": { "types": "./lib/types/dev/tsdown-config.d.ts", "default": "./lib/dev/tsdown-config.js" @@ -21,6 +25,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/dev/tsdown-config.js", "lib/local-plugin-loader-hooks.js", @@ -37,16 +42,22 @@ }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" }, "peerDependenciesMeta": { - "tsdown": { "optional": true }, - "tsx": { "optional": true } + "tsdown": { + "optional": true + }, + "tsx": { + "optional": true + } }, "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts new file mode 100644 index 0000000000..fd0a0ef55b --- /dev/null +++ b/packages/sdk/scripts/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-scripts`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-scripts/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' + +/** Cordis companion plugin name. */ +export const name = 'scripts-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 848de9a314..6ada45e1ac 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -6,8 +6,17 @@ }, "include": ["src"], "references": [ - { "path": "../helper" }, - { "path": "../../ui/app-boot" }, - { "path": "../../../vendor/cordis" } + { + "path": "../helper" + }, + { + "path": "../../ui/app-boot" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/scripts/tsdown.config.ts b/packages/sdk/scripts/tsdown.config.ts index 2218faa7be..14bb59b8bc 100644 --- a/packages/sdk/scripts/tsdown.config.ts +++ b/packages/sdk/scripts/tsdown.config.ts @@ -7,6 +7,10 @@ export default defineConfig([ fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }], }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, { entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index ddb9f2af4d..bc5c6d2c4c 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts new file mode 100644 index 0000000000..12c65db1c4 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-jsonl`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-jsonl-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e7dc469132..710a0ec9f1 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -21,17 +21,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } -async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { +async function expectFlushError(promise: Promise, message: RegExp): Promise { try { await promise } catch (error) { - expect(error).toBeInstanceOf(AggregateError) - const [cause] = (error as AggregateError).errors as unknown[] - expect(cause).toBeInstanceOf(Error) - expect((cause as Error).message).toMatch(message) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(message) return } - throw new Error('expected parallel flush to reject') + throw new Error('expected flush to reject') } async function freshRoot(): Promise { @@ -250,7 +248,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { appendClosedTurn(source) const child = ctx.sessions.fork(source, undefined, SessionId('persist-child')) - await ctx.parallel('session/flush', child) + await ctx.sessions.flush(child) const loaded = await ctx.sessionPersistence.load(child.id) expect(loaded.events).toEqual(source.events) @@ -429,12 +427,14 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) + a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) b.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', a) - await ctx.parallel('session/flush', b) + await ctx.sessions.flush(a) + await ctx.sessions.flush(b) const la = await ctx.sessionPersistence.load(SessionId('sa')) const lb = await ctx.sessionPersistence.load(SessionId('sb')) @@ -607,7 +607,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }, { inject: ['sessions'] })) // Drain A, then dispose ITS fiber (the live session A is gone) while the // backend stays loaded. - for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) + for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) await sessFiberA.dispose() // A new Session object reuses the id. Object-keyed initialization must run independently, @@ -675,7 +675,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) - for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) + for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) await firstFiber.dispose() let second!: Session @@ -783,18 +783,19 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Make the durable materialize fail on the next flush. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise } const origMat = backend.materialize.bind(backend) backend.materialize = () => Promise.reject(new Error('disk full')) - await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/) + await expectFlushError(ctx2.sessions.flush(session), /disk full/) // The events are STILL buffered (not silently dropped): a retry persists them. backend.materialize = origMat - await ctx2.parallel('session/flush', session) + await ctx2.sessions.flush(session) const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2]) await ctx2.fiber.dispose() }) diff --git a/packages/session-persistence/session-persistence-jsonl/tsconfig.json b/packages/session-persistence/session-persistence-jsonl/tsconfig.json index 23970f5a57..044156938b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tsconfig.json +++ b/packages/session-persistence/session-persistence-jsonl/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index f367b737b3..f65bdebd16 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts new file mode 100644 index 0000000000..a9b04cf5f5 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-sqlite`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-sqlite-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index f26edfa54d..ee599987bb 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -14,17 +14,15 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { +async function expectFlushError(promise: Promise, message: RegExp): Promise { try { await promise } catch (error) { - expect(error).toBeInstanceOf(AggregateError) - const [cause] = (error as AggregateError).errors as unknown[] - expect(cause).toBeInstanceOf(Error) - expect((cause as Error).message).toMatch(message) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(message) return } - throw new Error('expected parallel flush to reject') + throw new Error('expected flush to reject') } async function freshDbPath(): Promise { @@ -502,7 +500,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const b1 = await backend(path) const s1 = b1.ctx.sessions.create(SessionId('hmr-collide')) appendLog(s1, oneTurnLog()) - await b1.ctx.parallel('session/flush', s1) + await b1.ctx.sessions.flush(s1) await b1.dispose() // A fresh context with an UNRELATED live session reusing the id meets a @@ -513,9 +511,9 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await ctx.plugin(SessionPersistenceSqlite, { path }) - await expectParallelFlushError(ctx.parallel('session/flush', session), /id collision/) + await expectFlushError(ctx.sessions.flush(session), /id collision/) await ctx.fiber.dispose() }) }) @@ -567,18 +565,20 @@ describe('surface field round-trip', () => { const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('roundtrip-surface')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] }) + session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) + session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface')) - expect(loaded.events).toHaveLength(4) - const um = loaded.events[1]! + expect(loaded.events).toHaveLength(6) + const um = loaded.events[2]! expect((um as SurfaceEvent).surfaceOp).toBe('append') expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined() - const am = loaded.events[2]! + const am = loaded.events[3]! expect((am as SurfaceEvent).surfaceOp).toBe('append') - expect((am as SurfaceEvent).sourceEventSeqs).toEqual([0]) + expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2]) await fiber.dispose() }) @@ -590,7 +590,7 @@ describe('surface field round-trip', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq')) expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append') expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() diff --git a/packages/session-persistence/session-persistence-sqlite/tsconfig.json b/packages/session-persistence/session-persistence-sqlite/tsconfig.json index 23970f5a57..044156938b 100644 --- a/packages/session-persistence/session-persistence-sqlite/tsconfig.json +++ b/packages/session-persistence/session-persistence-sqlite/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index 91eef09007..a503b146ae 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -11,21 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts new file mode 100644 index 0000000000..cc7cc8fa2c --- /dev/null +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index defcaefb6d..47867c2f56 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { meta, oneTurnLog, appendLog } from './contract.ts' @@ -76,7 +77,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } }) send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('live')) expect(loaded.events).toHaveLength(6) @@ -96,7 +97,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) expect(loaded.meta.seedLength).toBe(3) @@ -111,17 +112,17 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(() => { ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' }).toThrow(TypeError) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('mutate')) - const first = loaded.events[0] - expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original') + const message = loaded.events.find(event => event.type === 'user/message') + expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original') } finally { await fiber.dispose() await fix.cleanup() @@ -166,7 +167,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(loaded.events).toEqual(seed) // A flush with no NEW events must not double-write. - await ctx.parallel('session/flush', forked) + await ctx.sessions.flush(forked) const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(reloaded.events).toEqual(seed) } finally { @@ -182,7 +183,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) - await first.ctx.parallel('session/flush', s1) + await first.ctx.sessions.flush(s1) } finally { await first.fiber.dispose() } @@ -194,7 +195,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.sessions.flush(s2) // let onCreated adopt s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await second.ctx.parallel('session/flush', s2) + await second.ctx.sessions.flush(s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) @@ -212,13 +213,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const fiber = await fix.mount(ctx) try { // The plugin seeded it on apply; a subsequent flush persists its events. - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing')) expect(loaded.events.length).toBeGreaterThanOrEqual(2) } finally { @@ -233,6 +235,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) const fiber = await fix.mount(ctx) const session = await liveSessionInFiber(ctx, 'drain', WORK) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // No explicit flush — dispose must drain. @@ -261,7 +264,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the // session stays live. The new instance has no coordinator state but must adopt the @@ -271,7 +274,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() + await expect(ctx.sessions.flush(session)).resolves.not.toThrow() const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) @@ -291,7 +294,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const backend1 = await fix.mount(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT // flushing turn 2: it is now ONLY in the live session's events; the new @@ -303,7 +306,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the // live suffix (turn 2) carried in the session's events. await fix.mount(ctx) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) @@ -322,7 +325,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const first = await fix.mount(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() @@ -332,7 +335,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // end. Adoption must truncate the torn tail but NOT synthesize closers. session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) @@ -352,7 +355,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) - await first.ctx.parallel('session/flush', s1) + await first.ctx.sessions.flush(s1) } finally { await first.fiber.dispose() } @@ -392,7 +395,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', reuse) + await ctx.sessions.flush(reuse) const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) } finally { @@ -438,14 +441,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Re-emit session/created for the SAME live session (idempotent initFor). - ctx.emit('session/created', session) - await ctx.parallel('session/flush', session) + ctx.emit(scopeTarget(session, undefined), 'session/created', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('idem')) - expect(loaded.events).toHaveLength(2) // not doubled + expect(loaded.events).toHaveLength(3) // not doubled } finally { await fiber.dispose() await fix.cleanup() @@ -690,11 +694,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) - expect(loaded.events).toHaveLength(2) + expect(loaded.events).toHaveLength(3) } finally { await fiber.dispose() await fix.cleanup() diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json index e817086a6a..cbd74a19e7 100644 --- a/packages/session-persistence/session-persistence/tsconfig.json +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index ae2767598a..040697391f 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -36,6 +42,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts new file mode 100644 index 0000000000..91bcee721e --- /dev/null +++ b/packages/session-query/session-query/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-query`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-query/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' + +/** Cordis companion plugin name. */ +export const name = 'session-query-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index f532edf168..658eb709d9 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -101,6 +101,8 @@ describe('session-query exact reads', () => { it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) const first = session.append( 'user/message', { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }, @@ -117,13 +119,14 @@ describe('session-query exact reads', () => { { surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] }, ) - expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) + expect((await ctx.sessionQuery.listEvents(session.id)).slice(2).map(record => record.surface)) .toEqual(['shadowed', 'log-only', 'current']) }) it('returns a bounded detached raw-event window and validates the request', async () => { const ctx = await liveContext({ readWindowMax: 1 }) const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) for (const text of ['one', 'two', 'three']) { session.append( 'user/message', @@ -132,14 +135,14 @@ describe('session-query exact reads', () => { ) } - const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 }) - expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1]) + const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 }) + expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2]) expect(result.session).toEqual(session.header) Object.assign(result.session, { createdAt: -1 }) if (result.events[0]?.type !== 'user/message') throw new Error('expected user message') result.events[0].data.content = [] expect(session.header.createdAt).not.toBe(-1) - expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) + expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1) await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) @@ -161,6 +164,7 @@ describe('session-query exact reads', () => { ]) const ctx = await liveContext() const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, @@ -170,7 +174,7 @@ describe('session-query exact reads', () => { expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted])) .toEqual([[shared.id, true, true], [durable.id, false, true]]) - const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 }) + const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 }) expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0]) .toMatchObject({ text: 'live' }) await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 })) @@ -189,6 +193,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = await liveContext() const live = ctx.sessions.create(SessionId('live')) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'user/message', { content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } }, @@ -198,8 +203,8 @@ describe('session-query exact reads', () => { TestPersistence.listFailure = new Error('list unavailable') TestPersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1) - await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } }) + await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } }) await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) @@ -228,19 +233,8 @@ describe('session-query exact reads', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('turns malformed surfaces and direct invalid config into typed errors', async () => { + it('turns persisted malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('bad-surface')) - ;(session as unknown as { log: SessionEvent[] }).log.push({ - type: 'assistant/message', - seq: 0, - time: 1, - data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } }, - surfaceOp: { op: 'replace', start: 9, end: 9 }, - }) - await expect(ctx.sessionQuery.listEvents(session.id)) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) - const persisted = header('bad-persisted-surface') TestPersistence.reset([{ meta: persisted, diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 03e1adad89..ee8b1d833f 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -89,6 +89,8 @@ function expectCode(code: SessionQueryErrorCode): Error { } function appendTraceEvents(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/chunk', { turn: 1, step: 1, @@ -97,22 +99,24 @@ function appendTraceEvents(session: Session): void { session.append( 'user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, - { surfaceOp: 'append', sourceEventSeqs: [0] }, + { surfaceOp: 'append', sourceEventSeqs: [2] }, ) session.append( 'assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] }, - { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] }, + { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, ) session.append( 'context/message', { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) session.append( 'assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] }, - { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] }, + { surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] }, ) } @@ -235,40 +239,40 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('trace')) appendTraceEvents(session) - const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 }) + const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 3 }) expect(original.target).toMatchObject({ sessionId: session.id, - seq: 1, + seq: 3, type: 'user/message', surface: 'shadowed', }) expect(original).toMatchObject({ - replacedBy: 2, - replacementChain: [2, 4], + replacedBy: 4, + replacementChain: [4, 8], replacedEventSeqs: [], - sourceEventSeqs: [0], - derivedEventSeqs: [2], + sourceEventSeqs: [2], + derivedEventSeqs: [4], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) .resolves.toMatchObject({ - replacedBy: 4, - replacementChain: [4], - replacedEventSeqs: [1], - sourceEventSeqs: [1, 0], - derivedEventSeqs: [4], + replacedBy: 8, + replacementChain: [8], + replacedEventSeqs: [3], + sourceEventSeqs: [3, 2], + derivedEventSeqs: [8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) .resolves.toMatchObject({ target: { surface: 'log-only' }, replacementChain: [], sourceEventSeqs: [], - derivedEventSeqs: [1, 2, 4], + derivedEventSeqs: [3, 4, 8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 })) .resolves.toMatchObject({ replacementChain: [], - replacedEventSeqs: [2], - sourceEventSeqs: [0, 2], + replacedEventSeqs: [4], + sourceEventSeqs: [2, 4], derivedEventSeqs: [], }) }) @@ -278,18 +282,18 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('detached')) appendTraceEvents(session) - const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) first.target.time = -1 first.replacementChain.push(99) first.replacedEventSeqs.push(99) first.sourceEventSeqs.push(99) first.derivedEventSeqs.push(99) - const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) expect(repeated.target.time).not.toBe(-1) - expect(repeated.replacementChain).toEqual([4]) - expect(repeated.replacedEventSeqs).toEqual([1]) - expect(repeated.sourceEventSeqs).toEqual([1, 0]) - expect(repeated.derivedEventSeqs).toEqual([4]) + expect(repeated.replacementChain).toEqual([8]) + expect(repeated.replacedEventSeqs).toEqual([3]) + expect(repeated.sourceEventSeqs).toEqual([3, 2]) + expect(repeated.derivedEventSeqs).toEqual([8]) }) it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { @@ -303,6 +307,7 @@ describe('session event tracing', () => { expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'context/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, @@ -310,7 +315,7 @@ describe('session event tracing', () => { ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) .resolves.toMatchObject({ target: { type: 'context/message' } }) expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 7153dae8bb..d7205907e1 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index d490438c51..be7c7baae2 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -34,6 +40,7 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts new file mode 100644 index 0000000000..475d02bb8f --- /dev/null +++ b/packages/skill/skill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-skill-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-skill-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' + +/** Cordis companion plugin name. */ +export const name = 'skill-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json index f51147abce..d471e269d0 100644 --- a/packages/skill/skill-local/tsconfig.json +++ b/packages/skill/skill-local/tsconfig.json @@ -6,11 +6,26 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/home" }, - { "path": "../../fs/fs" }, - { "path": "../skill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/home" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../skill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index c025de6ee9..3148a3f577 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -11,23 +11,30 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts new file mode 100644 index 0000000000..c1d4091bb2 --- /dev/null +++ b/packages/skill/skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-skill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-skill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill' + +/** Cordis companion plugin name. */ +export const name = 'skill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index 1b1855dcc4..e882ed2d72 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -6,8 +6,17 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 3d6ddc6b7e..47421bf19c 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts new file mode 100644 index 0000000000..abf4ba3961 --- /dev/null +++ b/packages/skill/tool-skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-skill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-skill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' + +/** Cordis companion plugin name. */ +export const name = 'tool-skill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/tsconfig.json b/packages/skill/tool-skill/tsconfig.json index 52ebb8bf9d..fed1ffa5f5 100644 --- a/packages/skill/tool-skill/tsconfig.json +++ b/packages/skill/tool-skill/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../core/scope" }, - { "path": "../../llm/llm" }, - { "path": "../../core/agent" }, - { "path": "../skill" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../skill" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index a75c4bfc0b..25a20db1d5 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -30,6 +36,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts new file mode 100644 index 0000000000..d638ffa2a9 --- /dev/null +++ b/packages/spill/spill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' + +/** Cordis companion plugin name. */ +export const name = 'spill-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/tsconfig.json b/packages/spill/spill-local/tsconfig.json index 8e818212f5..0cb209d8d0 100644 --- a/packages/spill/spill-local/tsconfig.json +++ b/packages/spill/spill-local/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../spill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../spill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 9c28ea5382..dac7d4311d 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts new file mode 100644 index 0000000000..d4aa544ecd --- /dev/null +++ b/packages/spill/spill-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' + +/** Cordis companion plugin name. */ +export const name = 'spill-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/tsconfig.json b/packages/spill/spill-policy/tsconfig.json index 6a81ab2f3c..71f19d381c 100644 --- a/packages/spill/spill-policy/tsconfig.json +++ b/packages/spill/spill-policy/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/retention" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../spill" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/retention" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../spill" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 3103c9cd11..c66306ff0a 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts new file mode 100644 index 0000000000..714e43f3a6 --- /dev/null +++ b/packages/spill/spill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill' + +/** Cordis companion plugin name. */ +export const name = 'spill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill/tsconfig.json b/packages/spill/spill/tsconfig.json index 0c2fd5c57f..30d0d29f0f 100644 --- a/packages/spill/spill/tsconfig.json +++ b/packages/spill/spill/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index fa16edcf60..2564afa8da 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", @@ -34,13 +40,14 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts new file mode 100644 index 0000000000..a5828fd4c1 --- /dev/null +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-acp`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-acp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' + +/** Cordis companion plugin name. */ +export const name = 'subagent-acp-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 5aa28528ac..175eb78e2f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 63f548d217..aa93b83e03 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", @@ -32,6 +38,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -41,7 +48,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts new file mode 100644 index 0000000000..c6903fd82d --- /dev/null +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-fork`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-fork/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' + +/** Cordis companion plugin name. */ +export const name = 'subagent-fork-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index bac12550af..a07a2319ef 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../subagent-inprocess" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index b9397e300c..69b573ecdd 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts new file mode 100644 index 0000000000..0eac204104 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-inprocess`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-inprocess/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' + +/** Cordis companion plugin name. */ +export const name = 'subagent-inprocess-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 7b7a015cc9..02fd8e53d0 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 1a986e69aa..f429025a5d 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -42,7 +49,6 @@ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts new file mode 100644 index 0000000000..d179a2b72a --- /dev/null +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-spawn`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-spawn/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' + +/** Cordis companion plugin name. */ +export const name = 'subagent-spawn-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index 219bf2a0c9..ee9ab096c2 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../subagent-inprocess" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json index 5f17459276..bd573b3c0c 100644 --- a/packages/subagent/subagent-subprocess/package.json +++ b/packages/subagent/subagent-subprocess/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts new file mode 100644 index 0000000000..22dac32814 --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-subprocess`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-subprocess/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' + +/** Cordis companion plugin name. */ +export const name = 'subagent-subprocess-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ b/packages/subagent/subagent-subprocess/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index aea05553e4..58b51d1888 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -33,6 +39,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts new file mode 100644 index 0000000000..3a79592ee1 --- /dev/null +++ b/packages/subagent/subagent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' + +/** Cordis companion plugin name. */ +export const name = 'subagent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index f93f929241..713e214f04 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index e2d4378743..6ed447dd56 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", @@ -35,6 +41,7 @@ "devDependencies": { "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts new file mode 100644 index 0000000000..08881e4b2b --- /dev/null +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-subagent`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-subagent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' + +/** Cordis companion plugin name. */ +export const name = 'tool-subagent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 2aa9d4f14e..25780c367f 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../tasks/tasks" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 64e1b0cbd7..abffcc2708 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,9 +32,11 @@ "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts new file mode 100644 index 0000000000..3579b96cf5 --- /dev/null +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-snapshot`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp-snapshot/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' + +/** Cordis companion plugin name. */ +export const name = 'acp-snapshot-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 9120df0ad1..893282ce51 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -8,6 +8,11 @@ "src" ], "references": [ - { "path": "../loader-smoke" } + { + "path": "../loader-smoke" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 423bd3e80d..aa04d85832 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -32,6 +38,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts new file mode 100644 index 0000000000..fc2554aa77 --- /dev/null +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-loop-testkit`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-agent-loop-testkit/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' + +/** Cordis companion plugin name. */ +export const name = 'agent-loop-testkit-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/support/agent-loop-testkit/tsconfig.json b/packages/support/agent-loop-testkit/tsconfig.json index 5e5b3c47f2..d24b4dd988 100644 --- a/packages/support/agent-loop-testkit/tsconfig.json +++ b/packages/support/agent-loop-testkit/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 1cb0e57838..c49771ded9 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,6 +1,6 @@ # dsh-invariants -Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions. +Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Every workspace package publishes a `./invariant` companion that registers its exact npm package name. ## Service: `InvariantService` (`ctx.invariants`) @@ -24,6 +24,10 @@ The service owns every registration fiber, while the returned disposer also belo ## Package companions +An ownership-only generated baseline installs no listeners but still reserves its package name through the real service boundary. A package replaces that marked file when it gains a relational check, retaining the same registration. `pnpm run verify-package-invariants` checks every package's source registration, export, published files, dependencies, TypeScript reference, and bundle entry. + +Four companions currently install stateful checks: + | Companion | Registration | Checks | |---|---|---| | `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | @@ -50,7 +54,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. +The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience @@ -58,6 +62,6 @@ None, as the service and companions observe runtime events and requests but neve ## Known Limitations and Deferred Work -- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one. +- Stateful checks cover only the four listed package contracts; other companions reserve ownership but add no listeners until their packages gain relational assertions. - Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index e06e9a3db7..d52dd0a14d 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 754cb46ab9..4bbf218e97 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,7 +1,8 @@ /** * Configurable registry for package-owned runtime invariant contributions. - * Packages register checks from optional `./invariant` companion plugins; - * ordinary package entrypoints stay independent of diagnostics. + * Every workspace package registers its name from a `./invariant` companion; + * ordinary package entrypoints stay independent of diagnostics, and packages + * without relational checks use an ownership-only installer. * * @module @deepseek-ai/dsh-invariants */ diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts new file mode 100644 index 0000000000..b6b4d8ae48 --- /dev/null +++ b/packages/support/invariants/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-invariants`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-invariants/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' + +/** Cordis companion plugin name. */ +export const name = 'invariants-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 403f3bda92..4a7e7dd8d8 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts new file mode 100644 index 0000000000..50295cbedf --- /dev/null +++ b/packages/support/llm-replay/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-replay`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-replay/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' + +/** Cordis companion plugin name. */ +export const name = 'llm-replay-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json index 95245937ec..673ee51547 100644 --- a/packages/support/llm-replay/tsconfig.json +++ b/packages/support/llm-replay/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index ddba421b41..570ee2c5ca 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -25,9 +30,11 @@ "tsx": "^4.22.4" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts new file mode 100644 index 0000000000..9265a5f8b4 --- /dev/null +++ b/packages/support/loader-smoke/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-loader-smoke`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-loader-smoke/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' + +/** Cordis companion plugin name. */ +export const name = 'loader-smoke-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/tsconfig.json b/packages/support/loader-smoke/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/support/loader-smoke/tsconfig.json +++ b/packages/support/loader-smoke/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 9e5f824418..128a8d2c4e 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tasks", - "description": "Background task registry (ctx.tasks) for the DeepSeek Harness \u2014 shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", + "description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", "version": "0.0.1", "private": true, "type": "module", @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -31,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts new file mode 100644 index 0000000000..468fe664b9 --- /dev/null +++ b/packages/tasks/tasks/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tasks`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tasks/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' + +/** Cordis companion plugin name. */ +export const name = 'tasks-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json index 392d3f8d8a..e29262ca74 100644 --- a/packages/tasks/tasks/tsconfig.json +++ b/packages/tasks/tasks/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index 9e0b19d307..fc1f96417d 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts new file mode 100644 index 0000000000..fded38c895 --- /dev/null +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-tasks`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-tasks/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' + +/** Cordis companion plugin name. */ +export const name = 'tool-tasks-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index 9e5411df25..feab4f3be8 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../tasks" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/timeout/timeout-policy/package.json b/packages/timeout/timeout-policy/package.json index aa351cb7a6..43dc9dfa28 100644 --- a/packages/timeout/timeout-policy/package.json +++ b/packages/timeout/timeout-policy/package.json @@ -11,23 +11,30 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts new file mode 100644 index 0000000000..9e7b5b7d4b --- /dev/null +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-timeout-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' + +/** Cordis companion plugin name. */ +export const name = 'timeout-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/tsconfig.json b/packages/timeout/timeout-policy/tsconfig.json index 8c0b47716e..55e50befde 100644 --- a/packages/timeout/timeout-policy/tsconfig.json +++ b/packages/timeout/timeout-policy/tsconfig.json @@ -6,11 +6,26 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../util/timeout" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index bab0f1230c..88d5e9b9c9 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts new file mode 100644 index 0000000000..a5980342f3 --- /dev/null +++ b/packages/todo/tool-todo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-todo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-todo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' + +/** Cordis companion plugin name. */ +export const name = 'tool-todo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/tsconfig.json b/packages/todo/tool-todo/tsconfig.json index adf2f25dec..f980e5ead1 100644 --- a/packages/todo/tool-todo/tsconfig.json +++ b/packages/todo/tool-todo/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..7d324d2e6f 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -29,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts new file mode 100644 index 0000000000..2c081fc48c --- /dev/null +++ b/packages/ui/acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp' + +/** Cordis companion plugin name. */ +export const name = 'acp-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..5f93e1589d 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 1eef56ae93..e2f0631e8b 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,11 +29,13 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts new file mode 100644 index 0000000000..498d967799 --- /dev/null +++ b/packages/ui/app-boot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-app-boot`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-app-boot/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' + +/** Cordis companion plugin name. */ +export const name = 'app-boot-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index 3171312de4..b85dc7f6a2 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../../vendor/include" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index e59fc4aaea..72dd9fe8c3 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", @@ -37,6 +43,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts new file mode 100644 index 0000000000..552c312481 --- /dev/null +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-jsonrpc/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' + +/** Cordis companion plugin name. */ +export const name = 'jsonrpc-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json index dcd57ef9af..14a70d8eaa 100644 --- a/packages/ui/jsonrpc/tsconfig.json +++ b/packages/ui/jsonrpc/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index e38e5a7bf1..bd8164f427 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts new file mode 100644 index 0000000000..1a774e2e4c --- /dev/null +++ b/packages/ui/permission/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-permission`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-permission/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-permission' + +/** Cordis companion plugin name. */ +export const name = 'permission-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 8b9cff62b4..3e7b747b2c 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../user-approval" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index e1bffdf171..f8473af7a3 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -41,6 +47,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts new file mode 100644 index 0000000000..443440a215 --- /dev/null +++ b/packages/ui/stdio/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-stdio/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' + +/** Cordis companion plugin name. */ +export const name = 'stdio-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index a3069462ff..4f8cf49e71 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -1,9 +1,9 @@ import { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' @@ -67,10 +67,26 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { /** Register a fake configured agent and cross the supported startup-work boundary. */ function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { const dispose = ctx.agents.register(agent) - ctx.emit('agent/session-start', agent, source) + agentEvents(ctx, agent).emit('agent/session-start', source) return dispose } +function emitAgentSessionStart(ctx: Context, agent: Agent, source: 'startup' | 'resume'): void { + agentEvents(ctx, agent).emit('agent/session-start', source) +} + +function emitAgentStatus(ctx: Context, agent: Agent, status: AgentStatus): void { + agentEvents(ctx, agent).emit('agent/status', status) +} + +function emitAgentDisposed(ctx: Context, agent: Agent): void { + agentEvents(ctx, agent).emit('agent/disposed') +} + +function emitSessionEvent(ctx: Context, session: Session, event: SessionEvent): void { + ctx.emit(scopeTarget(session, undefined), 'session/event', session, event) +} + /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ function makeSession(id: string): Session { return { id, header: { id } } as Session @@ -192,23 +208,23 @@ describe('createStdioChat rendering', () => { it('renders text-delta chunks verbatim', async () => { const { ctx, out } = await setup() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) + emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) expect(out.text()).toContain('hello') }) it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => { const { ctx, out } = await setup() const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) - ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer') }) it('ignores stream-chunk types it does not render', async () => { const { ctx, out } = await setup() const before = out.text() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) + emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) expect(out.text()).toBe(before) }) @@ -217,20 +233,20 @@ describe('createStdioChat rendering', () => { const agent = makeAgent('main') ctx.agents.register(agent) const session = agent.session - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 3] ') - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, - } as SessionEvent) + }) expect(out.text()).toContain('\n> ') }) it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() // No target exists, so the event's durable identity is the label. - ctx.emit('session/event', makeSession('orphan'), { + emitSessionEvent(ctx, makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[orphan turn 1] ') @@ -253,7 +269,7 @@ describe('createStdioChat rendering', () => { await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', agent.session, { + emitSessionEvent(ctx, agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') @@ -266,14 +282,14 @@ describe('createStdioChat rendering', () => { const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) - ctx.emit('agent/session-start', unrelated, 'startup') + emitAgentSessionStart(ctx, unrelated, 'startup') const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(resumed) await new Promise(resolve => setImmediate(resolve)) expect(resumed.sent).toEqual([]) - ctx.emit('agent/session-start', resumed, 'resume') + emitAgentSessionStart(ctx, resumed, 'resume') await new Promise(resolve => setImmediate(resolve)) expect(unrelated.sent).toEqual([]) @@ -283,10 +299,10 @@ describe('createStdioChat rendering', () => { it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) + emitSessionEvent(ctx, session, { type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, - } as SessionEvent) + }) expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) @@ -297,7 +313,7 @@ describe('createStdioChat rendering', () => { dispose() // After disposal the event belongs to a non-target session, so its durable // identity is rendered directly. - ctx.emit('session/event', agent.session, { + emitSessionEvent(ctx, agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 1] ') @@ -307,8 +323,8 @@ describe('createStdioChat rendering', () => { const { ctx, out } = await setup() const target = makeAgent('main') ctx.agents.register(target) - ctx.emit('agent/disposed', makeAgent('other')) - ctx.emit('session/event', target.session, { + emitAgentDisposed(ctx, makeAgent('other')) + emitSessionEvent(ctx, target.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 1] ') @@ -326,7 +342,7 @@ describe('createStdioChat rendering', () => { input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) expect(replacement.sent).toEqual([]) - ctx.emit('agent/session-start', replacement, 'resume') + emitAgentSessionStart(ctx, replacement, 'resume') await new Promise(resolve => setImmediate(resolve)) expect(prefixCollision.sent).toEqual([]) @@ -356,28 +372,28 @@ describe('createStdioChat rendering', () => { type: 'tool/call', seq: 1, time: 0, data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }, } as SessionEvent - ctx.emit('session/event', session, callEvent) + emitSessionEvent(ctx, session, callEvent) expect(out.text()).toContain('[tool call] bash({"command":"ls"})') const resultEvent = { type: 'tool/result', seq: 2, time: 0, data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false }, } as SessionEvent - ctx.emit('session/event', session, resultEvent) + emitSessionEvent(ctx, session, resultEvent) expect(out.text()).toContain('[tool result] file.txt') }) it('renders a todo/write session event as a glyphed checklist', async () => { const { ctx, out } = await setup() const session = {} as Session - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'todo/write', seq: 1, time: 0, data: { todos: [ { content: 'read the code', status: 'completed' }, { content: 'write the fix', status: 'in_progress' }, { content: 'run the tests', status: 'pending' }, ] }, - } as SessionEvent) + }) const text = out.text() expect(text).toContain('[todos]') expect(text).toContain('[x] read the code') @@ -387,19 +403,19 @@ describe('createStdioChat rendering', () => { it('resets dim styling when a todo/write interrupts reasoning', async () => { const { ctx, out } = await setup() - ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', {} as Session, { + emitSessionEvent(ctx, {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) + emitSessionEvent(ctx, {} as Session, { type: 'todo/write', seq: 1, time: 0, data: { todos: [{ content: 'a task', status: 'pending' }] }, - } as SessionEvent) + }) expect(out.text()).toContain('\x1B[2mr\x1B[0m') }) it('resets dim styling when a tool/call interrupts reasoning', async () => { const { ctx, out } = await setup() const session = {} as Session - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) + emitSessionEvent(ctx, session, { type: 'tool/call', seq: 1, time: 0, data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, } as SessionEvent) @@ -409,10 +425,10 @@ describe('createStdioChat rendering', () => { it('ignores session events it does not render', async () => { const { ctx, out } = await setup() const before = out.text() - ctx.emit('session/event', {} as Session, { + emitSessionEvent(ctx, {} as Session, { type: 'user/message', seq: 1, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, - } as SessionEvent) + }) expect(out.text()).toBe(before) }) }) @@ -799,7 +815,7 @@ describe('createStdioChat input', () => { ctx.agents.register(agent) await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') + emitAgentSessionStart(ctx, agent, 'startup') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) @@ -860,9 +876,9 @@ describe('createStdioChat EOF exit', () => { // Work submitted but no 'running' observed yet — must NOT exit. expect(exit).not.toHaveBeenCalled() // The turn starts, then settles. - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledWith(0) }) @@ -878,12 +894,12 @@ describe('createStdioChat EOF exit', () => { ctx.agents.register(agent) await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') + emitAgentSessionStart(ctx, agent, 'startup') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledWith(0) }) @@ -913,14 +929,14 @@ describe('createStdioChat EOF exit', () => { registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') // sawRunning = true + emitAgentStatus(ctx, agent, 'running') // sawRunning = true input.finish() await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed ;(agent as { status: AgentStatus }).status = 'idle' // Two idle signals while stdin is already closed: the first arms the timer, // the second must hit the already-scheduled guard, not arm a second. - ctx.emit('agent/status', agent, 'idle') - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledTimes(1) }) @@ -933,8 +949,8 @@ describe('createStdioChat EOF exit', () => { await new Promise(r => setImmediate(r)) input.finish() const other = makeAgent('other') - ctx.emit('agent/status', other, 'running') - ctx.emit('agent/status', other, 'idle') + emitAgentStatus(ctx, other, 'running') + emitAgentStatus(ctx, other, 'idle') await flushExit() expect(exit).not.toHaveBeenCalled() }) @@ -945,11 +961,11 @@ describe('createStdioChat EOF exit', () => { registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'running' input.finish() // sawRunning is true, but the agent is still running — the idle gate holds. - ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running' + emitAgentStatus(ctx, agent, 'idle') // a stale/duplicate signal while status stays 'running' await flushExit() expect(exit).not.toHaveBeenCalled() }) @@ -998,8 +1014,8 @@ describe('createStdioChat disposal (HMR safety)', () => { // After dispose, status transitions must neither throw nor schedule an exit // (the listener and the EOF-exit path are both torn down). expect(() => { - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'running') + emitAgentStatus(ctx, agent, 'idle') }).not.toThrow() await flushExit() expect(exit).not.toHaveBeenCalled() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json index e0c578ed32..c37d7ed522 100644 --- a/packages/ui/stdio/tsconfig.json +++ b/packages/ui/stdio/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json index 5ee90f0818..d2523ce220 100644 --- a/packages/ui/tool-ask-user/package.json +++ b/packages/ui/tool-ask-user/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts new file mode 100644 index 0000000000..eebb1ced42 --- /dev/null +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-ask-user`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-ask-user/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' + +/** Cordis companion plugin name. */ +export const name = 'tool-ask-user-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/tsconfig.json b/packages/ui/tool-ask-user/tsconfig.json index c779bad37f..6a55e89abe 100644 --- a/packages/ui/tool-ask-user/tsconfig.json +++ b/packages/ui/tool-ask-user/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..35d51fbd2f 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -38,6 +44,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts new file mode 100644 index 0000000000..cba5fd9d2e --- /dev/null +++ b/packages/ui/tui/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tui`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tui/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tui' + +/** Cordis companion plugin name. */ +export const name = 'tui-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..fcf8105350 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -63,6 +63,11 @@ export async function createTuiTestHarness { await renderAfter(harness, () => { appendUser(harness.session, 'Show the live update.') harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'block-start', index: 1, blockType: 'text' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, }) }) @@ -345,9 +346,10 @@ describe('TUI terminal-state snapshots', () => { source: { kind: 'user' }, reason: `Unsafe policy ${CONTROL_PROBE}`, }) + session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { - turn: 7, - reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` }, + turn: 1, + reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` }, }) }, }, { columns: 100, rows: 34 }) @@ -369,7 +371,7 @@ describe('TUI terminal-state snapshots', () => { const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await harness.terminal.waitForFrame(beforeQuestion) await renderAfter(harness, () => { - harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) }) await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) @@ -427,14 +429,14 @@ describe('TUI terminal-state snapshots', () => { }, { surfaceOp: 'append' }) const assistant = session.append('assistant/message', { turn: 1, - step: 0, + step: 1, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) const result = session.append('tool/result', { turn: 1, - step: 0, + step: 1, callId: CallId('old-tool'), content: [{ type: 'text', text: 'obsolete output that must disappear' }], isError: false, @@ -470,13 +472,15 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') harness.terminal.send('/unknown-advanced-command') harness.terminal.send('\r') - harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output')) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output')) + harness.session.append('step/end', { turn: 1, step: 1 }) harness.session.append('turn/end', { - turn: 3, + turn: 1, reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' }, }) + harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) harness.session.append('turn/end', { - turn: 4, + turn: 2, reason: { kind: 'interrupted' }, }) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..c4520090d7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -174,7 +174,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('↑1.3k ↓42') result.agent.status = 'running' - result.ctx.emit('agent/status', result.agent, 'running') + agentEvents(result.ctx, result.agent).emit('agent/status', 'running') result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -182,69 +182,77 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) - result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } }) - result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } }) - result.session.append('step/start', { turn: 11, step: 0 }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('step/start', { turn: 3, step: 1 }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 1, blockType: 'text' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'text-delta', index: 1, text: 'live answer' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 2, blockType: 'tool-call' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } }, }) await tick() expect(result.terminal.output).toContain('live thought') result.terminal.send('\x12') await tick() - appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) + appendAssistant( + result.session, + [{ type: 'text', text: 'final live answer' }], + { inputTokens: 500, outputTokens: 8 }, + { turn: 3, step: 1 }, + ) await tick() expect(result.terminal.output).toContain('Working') @@ -258,17 +266,17 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('assistant/chunk', { turn: 3, - step: 0, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'cleared stream' }, }) result.terminal.send('/clear') result.terminal.send('\r') - appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }]) + appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 }) await tick() expect(result.terminal.output).toContain('answer after clear') result.agent.status = 'idle' - result.ctx.emit('agent/status', result.agent, 'idle') + agentEvents(result.ctx, result.agent).emit('agent/status', 'idle') await tick() expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) @@ -324,8 +332,8 @@ describe('pi-tui chat lifecycle and transcript', () => { appendUser(session, 'first prompt') appendUser(session, 'second prompt') session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'stale partial response' }, }) }, @@ -442,18 +450,25 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } + unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) - events.ctx.emit('agent/status', unrelatedAgent, 'running') - events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error')) - events.ctx.emit('agent/disposed', unrelatedAgent) - events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure')) - events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } }) - events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } }) - events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } }) - events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) - events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } }) - events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } }) - events.ctx.emit('agent/disposed', events.agent) + agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') + agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error')) + agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed') + agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure')) + events.session.append('step/end', { turn: 1, step: 1 }) + events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } }) + events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } }) + events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } }) + events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } }) + events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } }) + events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } }) + agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') @@ -546,7 +561,7 @@ describe('tool cards and surface replay', () => { })), ]) for (const [id, name, args] of calls) { - result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args }) + result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args }) } await tick() expect(result.terminal.output).toContain('$ raw command') @@ -556,23 +571,23 @@ describe('tool cards and surface replay', () => { expect(result.terminal.output).toContain('call presenter boom') expect(result.terminal.output).toContain('Symbol(input)') result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false, + turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true, + turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false, + turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false, + turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false, + turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false, meta: { value: 1 }, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c7' as never, + turn: 1, step: 1, callId: 'c7' as never, content: [ { type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' }, { type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] }, @@ -581,13 +596,18 @@ describe('tool cards and surface replay', () => { isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false, + turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, + turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false, + turn: 1, + step: 1, + callId: 'orphan' as never, + content: [{ type: 'text', text: 'orphan result' }], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, }, { surfaceOp: 'append' }) await tick() @@ -623,15 +643,15 @@ describe('tool cards and surface replay', () => { appendUser(result.session, 'old prompt') const assistant = result.session.append('assistant/message', { turn: 1, - step: 0, + step: 1, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) result.session.append('tool/call', { - turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}', + turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}', }) const toolResult = result.session.append('tool/result', { - turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, + turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, }, { surfaceOp: 'append' }) const start = result.session.surface.nodes[0] as number result.session.append('context/message', { @@ -904,6 +924,8 @@ describe('terminal mounting', () => { await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), @@ -919,7 +941,7 @@ describe('terminal mounting', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) session.append('assistant/chunk', { turn: 1, - step: 0, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'must not render' }, }) await tick() diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..134b87248b 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/user-approval/package.json b/packages/ui/user-approval/package.json index 1696a7b603..098a0cc44b 100644 --- a/packages/ui/user-approval/package.json +++ b/packages/ui/user-approval/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -36,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts new file mode 100644 index 0000000000..73dee4f9f1 --- /dev/null +++ b/packages/ui/user-approval/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-user-approval`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-user-approval/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' + +/** Cordis companion plugin name. */ +export const name = 'user-approval-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 5592063e45..fe2643cb3f 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' @@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => { } const preStep = (ctx: Context, agent: Agent): Promise => - ctx.serial('agent/pre-step', agent, 1, 1, new AbortController().signal) + agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal) /** Append a `request/header` snapshot whose system text is exactly `system`. */ function appendHeader(session: Session, system: string): void { diff --git a/packages/ui/user-approval/tsconfig.json b/packages/ui/user-approval/tsconfig.json index fb9a9e6e2d..2fe19338ca 100644 --- a/packages/ui/user-approval/tsconfig.json +++ b/packages/ui/user-approval/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json index f4c9c411fd..bcabc951df 100644 --- a/packages/ui/user-interaction/package.json +++ b/packages/ui/user-interaction/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts new file mode 100644 index 0000000000..262681fc06 --- /dev/null +++ b/packages/ui/user-interaction/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-user-interaction`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-user-interaction/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' + +/** Cordis companion plugin name. */ +export const name = 'user-interaction-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-interaction/tsconfig.json b/packages/ui/user-interaction/tsconfig.json index 178ff39f3f..1361d87c20 100644 --- a/packages/ui/user-interaction/tsconfig.json +++ b/packages/ui/user-interaction/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 7074aaa621..51ce4d795d 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts new file mode 100644 index 0000000000..e932e7e35e --- /dev/null +++ b/packages/util/brand/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-brand`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-brand/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-brand' + +/** Cordis companion plugin name. */ +export const name = 'brand-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/brand/tsconfig.json +++ b/packages/util/brand/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/home/package.json b/packages/util/home/package.json index efeaf4832c..07795b1a07 100644 --- a/packages/util/home/package.json +++ b/packages/util/home/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts new file mode 100644 index 0000000000..5874a57c1c --- /dev/null +++ b/packages/util/home/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-home`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-home/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-home' + +/** Cordis companion plugin name. */ +export const name = 'home-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json index 9770ef25d6..67d4461281 100644 --- a/packages/util/home/tsconfig.json +++ b/packages/util/home/tsconfig.json @@ -5,5 +5,9 @@ "outDir": "lib/types" }, "include": ["src"], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index b4f760afe9..601a2941b2 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts new file mode 100644 index 0000000000..c2cedfbb0d --- /dev/null +++ b/packages/util/paths/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-paths`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-paths/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-paths' + +/** Cordis companion plugin name. */ +export const name = 'paths-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/paths/tsconfig.json b/packages/util/paths/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/paths/tsconfig.json +++ b/packages/util/paths/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index db8bab3342..312bebd624 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts new file mode 100644 index 0000000000..7516f9e5ed --- /dev/null +++ b/packages/util/retention/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-retention`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-retention/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-retention' + +/** Cordis companion plugin name. */ +export const name = 'retention-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/retention/tsconfig.json b/packages/util/retention/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/retention/tsconfig.json +++ b/packages/util/retention/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 381b9d269e..615d21759e 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts new file mode 100644 index 0000000000..15140bb880 --- /dev/null +++ b/packages/util/timeout/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-timeout/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' + +/** Cordis companion plugin name. */ +export const name = 'timeout-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/timeout/tsconfig.json b/packages/util/timeout/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/timeout/tsconfig.json +++ b/packages/util/timeout/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 23ed7157a2..ec1d33f4d4 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,13 +39,14 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts new file mode 100644 index 0000000000..008fe2f5e1 --- /dev/null +++ b/packages/web/tool-web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-web`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-web/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' + +/** Cordis companion plugin name. */ +export const name = 'tool-web-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json index 5226425ec6..f6684272ce 100644 --- a/packages/web/tool-web/tsconfig.json +++ b/packages/web/tool-web/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../../timeout/timeout-policy" }, - { "path": "../web" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../timeout/timeout-policy" + }, + { + "path": "../web" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 1e1d7ea71b..5c42bfa09c 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts new file mode 100644 index 0000000000..ef61e2a611 --- /dev/null +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-fetch-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-fetch-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' + +/** Cordis companion plugin name. */ +export const name = 'web-fetch-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/tsconfig.json b/packages/web/web-fetch-local/tsconfig.json index c6fb75a5c1..29e7c5078c 100644 --- a/packages/web/web-fetch-local/tsconfig.json +++ b/packages/web/web-fetch-local/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 42471006c0..b38f552957 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts new file mode 100644 index 0000000000..8781949be9 --- /dev/null +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-deepseek`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-deepseek/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' + +/** Cordis companion plugin name. */ +export const name = 'web-search-deepseek-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 240909e24a..7d6b802d2e 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts new file mode 100644 index 0000000000..a2dd956625 --- /dev/null +++ b/packages/web/web-search-exa/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-exa`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-exa/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' + +/** Cordis companion plugin name. */ +export const name = 'web-search-exa-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 26d077fada..9aa7080431 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts new file mode 100644 index 0000000000..fe82c79dae --- /dev/null +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-perplexity`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-perplexity/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' + +/** Cordis companion plugin name. */ +export const name = 'web-search-perplexity-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web/package.json b/packages/web/web/package.json index b94f7ba685..7b732ec819 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts new file mode 100644 index 0000000000..b9b1b0d45d --- /dev/null +++ b/packages/web/web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web' + +/** Cordis companion plugin name. */ +export const name = 'web-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/web/web/tsconfig.json b/packages/web/web/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/web/web/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 327e6ec3a9..dd055ad9ec 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts new file mode 100644 index 0000000000..cd1f0e475b --- /dev/null +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-workflow`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-workflow/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' + +/** Cordis companion plugin name. */ +export const name = 'tool-workflow-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index f66eda75a7..c08ae597f2 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../workflow" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 1cd6e07e17..a915229449 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -20,6 +24,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -29,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts new file mode 100644 index 0000000000..6bcc40862c --- /dev/null +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow-workerthread`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workflow-workerthread/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' + +/** Cordis companion plugin name. */ +export const name = 'workflow-workerthread-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/tsconfig.json b/packages/workflow/workflow-workerthread/tsconfig.json index 730a3e61d9..37d90aaea1 100644 --- a/packages/workflow/workflow-workerthread/tsconfig.json +++ b/packages/workflow/workflow-workerthread/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../workflow" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/workflow-workerthread/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts index 8ebd93d89f..962a3d9078 100644 --- a/packages/workflow/workflow-workerthread/tsdown.config.ts +++ b/packages/workflow/workflow-workerthread/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 476866382a..b8ce1ebc7f 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts new file mode 100644 index 0000000000..b552021ca4 --- /dev/null +++ b/packages/workflow/workflow/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workflow/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' + +/** Cordis companion plugin name. */ +export const name = 'workflow-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/tsconfig.json b/packages/workflow/workflow/tsconfig.json index 6ec42e0bfe..76ad9f725a 100644 --- a/packages/workflow/workflow/tsconfig.json +++ b/packages/workflow/workflow/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43aae285c7..b13f346cd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: packages/bash/bash: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -228,6 +231,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -247,6 +253,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -284,6 +293,9 @@ importers: '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -320,6 +332,9 @@ importers: packages/code-runtime/code-runtime: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -333,12 +348,18 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/compact/compact: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -407,6 +428,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -447,6 +471,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -493,6 +520,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -606,6 +636,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -628,6 +661,9 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -667,6 +703,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -770,6 +809,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -801,6 +843,9 @@ importers: specifier: workspace:^ version: link:../../ui/app-boot devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -828,6 +873,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -870,6 +918,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -886,6 +937,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -898,6 +952,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -932,6 +989,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../fs-policy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -966,6 +1026,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1003,6 +1066,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1021,6 +1087,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1052,6 +1121,9 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1098,6 +1170,9 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1122,6 +1197,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1132,6 +1210,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1148,6 +1229,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1164,6 +1248,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1183,6 +1270,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1204,6 +1294,9 @@ importers: packages/sandbox/sandbox: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1220,6 +1313,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1239,6 +1335,9 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1270,6 +1369,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:^ version: link:../../hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -1301,6 +1403,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1313,6 +1418,12 @@ importers: packages/session-persistence/session-persistence: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1326,6 +1437,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1342,6 +1456,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1358,6 +1475,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1377,6 +1497,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1396,6 +1519,9 @@ importers: '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -1412,6 +1538,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1436,6 +1565,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1455,6 +1587,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1477,6 +1612,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1504,6 +1642,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1535,6 +1676,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1678,6 +1822,9 @@ importers: packages/subagent/subagent-subprocess: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1694,6 +1841,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1728,6 +1878,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1740,6 +1893,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1768,6 +1924,9 @@ importers: packages/support/llm-replay: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1784,6 +1943,9 @@ importers: specifier: ^4.22.4 version: 4.22.4 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1796,6 +1958,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1815,6 +1980,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1836,6 +2004,9 @@ importers: packages/timeout/timeout-policy: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1860,6 +2031,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1966,6 +2140,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -1985,6 +2162,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../examples/agent-spine-demo + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2016,6 +2196,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -2044,6 +2227,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2062,6 +2248,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2096,6 +2285,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2139,6 +2331,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2160,6 +2355,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2169,30 +2367,45 @@ importers: packages/util/brand: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/home: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/paths: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/retention: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/timeout: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2206,6 +2419,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2246,6 +2462,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2259,6 +2478,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -2275,6 +2497,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2288,6 +2513,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2301,6 +2529,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2317,6 +2548,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2350,6 +2584,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 224f8540b8..2a9cf01bdb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -93,37 +93,6 @@ function workspaceManifests(): WorkspaceManifest[] { return manifests } -const dshPackageFiles = [ - 'lib/index.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshBinPackageFiles = [ - 'lib/index.js', - 'lib/bin.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshWorkerPackageFiles = [ - 'lib/index.js', - 'lib/worker.cjs', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshInvariantPackageFiles = [ - 'lib/index.js', - 'lib/invariant.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-scripts': [ @@ -139,25 +108,18 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] - if (extras.length > 0) { - return [ - 'lib/index.js', - ...manifest.bin ? ['lib/bin.js'] : [], - ...extras, - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', - ] - } - if (manifest.bin) return dshBinPackageFiles - // Package-owned diagnostic companions are separately bundled optional - // entries; their source stays in the owner package without bloating root. - if (manifest.exports?.['./invariant']) return dshInvariantPackageFiles - // A declared "./worker" subpath export sanctions the one extra runtime - // bundle a worker-thread entry needs (and NodeNext/publint then validate - // that subpath's targets like any other export). - if (manifest.exports?.['./worker']) return dshWorkerPackageFiles - return dshPackageFiles + return [ + 'lib/index.js', + // Every package publishes its invariant ownership companion as a separate + // bundle; the package-invariant gate validates the companion itself. + 'lib/invariant.js', + ...manifest.bin ? ['lib/bin.js'] : [], + ...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [], + ...extras, + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', + ] } function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { diff --git a/scripts/gen-package-invariants.ts b/scripts/gen-package-invariants.ts new file mode 100644 index 0000000000..d30ce5a3eb --- /dev/null +++ b/scripts/gen-package-invariants.ts @@ -0,0 +1,44 @@ +/** Generate or verify package-owned invariant companion baselines. */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + GENERATED_INVARIANT_MARKER, + collectPackageInvariantViolations, + formatPackageInvariantViolation, + packageInvariantOwners, + renderBaselineInvariant, +} from './package-invariants.ts' + +const root = resolve(import.meta.dirname, '..') +const check = process.argv.includes('--check') + +if (!check) { + let generated = 0 + for (const owner of packageInvariantOwners(root)) { + const path = resolve(root, owner.sourcePath) + let current: string | undefined + try { + current = readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (current !== undefined && !current.includes(GENERATED_INVARIANT_MARKER)) continue + const expected = renderBaselineInvariant(owner) + if (current === expected) continue + writeFileSync(path, expected) + generated += 1 + } + console.log(`gen-package-invariants: wrote ${generated} generated baseline companion(s).`) +} + +const violations = collectPackageInvariantViolations(root) +if (violations.length > 0) { + console.error('verify-package-invariants: violations found:') + for (const violation of violations) { + console.error(` ${formatPackageInvariantViolation(root, violation)}`) + } + process.exit(1) +} + +console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} package companion(s) conform.`) diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts new file mode 100644 index 0000000000..698969b3d7 --- /dev/null +++ b/scripts/package-invariants.spec.ts @@ -0,0 +1,104 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + collectPackageInvariantViolations, + packageInvariantOwners, + renderBaselineInvariant, +} from './package-invariants.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(options: { + packageName?: string + source?: string + invariantExport?: boolean + invariantDependency?: boolean + invariantReference?: boolean + buildEntry?: boolean +} = {}): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-')) + roots.push(root) + const dir = join(root, 'packages/core/probe') + mkdirSync(join(dir, 'src'), { recursive: true }) + const packageName = options.packageName ?? '@deepseek-ai/dsh-probe' + const manifest = { + name: packageName, + exports: options.invariantExport === false ? {} : { + './invariant': { + types: './lib/types/invariant.d.ts', + default: './lib/invariant.js', + }, + }, + files: ['lib/index.js', 'lib/invariant.js', 'src'], + peerDependencies: options.invariantDependency === false ? {} : { + '@deepseek-ai/dsh-invariants': '^0.0.1', + }, + devDependencies: options.invariantDependency === false ? {} : { + '@deepseek-ai/dsh-invariants': 'workspace:^', + }, + } + writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + const owner = packageInvariantOwners(root)[0]! + writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? renderBaselineInvariant(owner)) + writeFileSync( + join(dir, 'tsdown.config.ts'), + options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n", + ) + return root +} + +describe('package invariant gate', () => { + it('accepts a generated owner companion with publication metadata', () => { + expect(collectPackageInvariantViolations(fixture())).toEqual([]) + }) + + it('rejects missing publication metadata and build output', () => { + const violations = collectPackageInvariantViolations(fixture({ + invariantExport: false, + invariantDependency: false, + invariantReference: false, + buildEntry: false, + })) + expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('exports["./invariant"]'), + expect.stringContaining('peerDependency'), + expect.stringContaining('devDependency'), + expect.stringContaining('TypeScript project references'), + expect.stringContaining('must bundle lib/types/invariant.js'), + ])) + }) + + it('rejects foreign, duplicate, and unresolved registrations', () => { + const source = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const selected = process.env.PACKAGE_NAME +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => { + ctx.invariants.register('@deepseek-ai/dsh-foreign', () => {}) + return ctx.invariants.register(selected!, () => {}) +} +` + const violations = collectPackageInvariantViolations(fixture({ source })) + expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('must resolve to a local string constant'), + expect.stringContaining('must register exactly its own package name'), + ])) + }) + + it('rejects edits to a generated baseline', () => { + const root = fixture() + const path = join(root, 'packages/core/probe/src/invariant.ts') + writeFileSync(path, `${renderBaselineInvariant(packageInvariantOwners(root)[0]!)}// stale\n`) + expect(collectPackageInvariantViolations(root).map(violation => violation.message)) + .toContain('generated baseline is stale; run pnpm run gen-package-invariants') + }) +}) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts new file mode 100644 index 0000000000..45fb64829f --- /dev/null +++ b/scripts/package-invariants.ts @@ -0,0 +1,283 @@ +/** + * Package-invariant companion discovery, generation, and structural checks. + * The runtime registry stays product-independent; this gate makes ownership + * exhaustive across packages without centralizing package checks. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { basename, dirname, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +/** Marker identifying baseline companions owned by this generator. */ +export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' + +interface PackageManifest { + name?: string + exports?: Record + files?: string[] + peerDependencies?: Record + devDependencies?: Record +} + +/** One package and the files participating in its invariant publication contract. */ +export interface PackageInvariantOwner { + readonly dir: string + readonly manifestPath: string + readonly sourcePath: string + readonly packageName: string +} + +/** One gate violation with a repo-relative owner path. */ +export interface PackageInvariantViolation { + readonly path: string + readonly message: string +} + +/** Discover every package under the repository package tree. */ +export function packageInvariantOwners(root: string): PackageInvariantOwner[] { + return globSync('packages/*/*/package.json', { cwd: root }) + .map(path => path.split(sep).join('/')) + .sort() + .map((manifestPath) => { + const manifest = readManifest(resolve(root, manifestPath)) + if (manifest.name === undefined || manifest.name === '') { + throw new Error(`${manifestPath}: package invariant owner must declare a package name`) + } + const dir = dirname(manifestPath) + return { + dir, + manifestPath, + sourcePath: `${dir}/src/invariant.ts`, + packageName: manifest.name, + } + }) +} + +/** Render the generated ownership-only companion for a package without custom checks. */ +export function renderBaselineInvariant(owner: PackageInvariantOwner): string { + const serviceImport = owner.packageName === '@deepseek-ai/dsh-invariants' + ? './index.ts' + : '@deepseek-ai/dsh-invariants' + const pluginName = `${basename(owner.dir)}-invariant` + return `/** + * Generated invariant ownership companion for \`${owner.packageName}\`. + * Replace this file with package-owned checks while preserving its registration. + * + * ${GENERATED_INVARIANT_MARKER} + * @module ${owner.packageName}/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '${serviceImport}' + +const PACKAGE_NAME = '${owner.packageName}' + +/** Cordis companion plugin name. */ +export const name = '${pluginName}' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ +` +} + +/** Return all violations of the package-invariant companion contract. */ +export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { + const violations: PackageInvariantViolation[] = [] + for (const owner of packageInvariantOwners(root)) { + const manifest = readManifest(resolve(root, owner.manifestPath)) + checkManifest(owner, manifest, violations) + checkBuild(owner, root, violations) + checkSource(owner, root, violations) + } + return violations +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +function addViolation( + violations: PackageInvariantViolation[], + path: string, + message: string, +): void { + violations.push({ path, message }) +} + +function checkManifest( + owner: PackageInvariantOwner, + manifest: PackageManifest, + violations: PackageInvariantViolation[], +): void { + const invariantExport = manifest.exports?.['./invariant'] + if (typeof invariantExport !== 'object' + || invariantExport.types !== './lib/types/invariant.d.ts' + || invariantExport.default !== './lib/invariant.js') { + addViolation( + violations, + owner.manifestPath, + 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js', + ) + } + if (!manifest.files?.includes('lib/invariant.js')) { + addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js') + } + if (owner.packageName === '@deepseek-ai/dsh-invariants') return + if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') { + addViolation( + violations, + owner.manifestPath, + '@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency', + ) + } + if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { + addViolation( + violations, + owner.manifestPath, + '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency', + ) + } +} + +function checkBuild( + owner: PackageInvariantOwner, + root: string, + violations: PackageInvariantViolation[], +): void { + const tsconfigPath = `${owner.dir}/tsconfig.json` + const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as { + references?: Array<{ path?: string }> + } + if (owner.packageName !== '@deepseek-ai/dsh-invariants' + && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) { + addViolation( + violations, + tsconfigPath, + 'TypeScript project references must include ../../support/invariants', + ) + } + + const configPath = `${owner.dir}/tsdown.config.ts` + if (!existsSync(resolve(root, configPath))) return + const source = readFileSync(resolve(root, configPath), 'utf8') + if (!source.includes('lib/types/invariant.js')) { + addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js') + } +} + +function checkSource( + owner: PackageInvariantOwner, + root: string, + violations: PackageInvariantViolation[], +): void { + const absolutePath = resolve(root, owner.sourcePath) + if (!existsSync(absolutePath)) { + addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') + return + } + const sourceText = readFileSync(absolutePath, 'utf8') + if (sourceText.includes(GENERATED_INVARIANT_MARKER) + && sourceText !== renderBaselineInvariant(owner)) { + addViolation( + violations, + owner.sourcePath, + 'generated baseline is stale; run pnpm run gen-package-invariants', + ) + } + + const sourceFile = ts.createSourceFile( + absolutePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + const constants = topLevelStringConstants(sourceFile) + const registrations: string[] = [] + const unresolved: number[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) { + const argument = node.arguments[0] + const packageName = argument === undefined ? undefined : stringValue(argument, constants) + if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1) + else registrations.push(packageName) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + + for (const line of unresolved) { + addViolation( + violations, + owner.sourcePath, + `line ${line}: ctx.invariants.register package name must resolve to a local string constant`, + ) + } + if (registrations.length !== 1 || registrations[0] !== owner.packageName) { + addViolation( + violations, + owner.sourcePath, + `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`, + ) + } + for (const exportedName of ['name', 'inject', 'apply']) { + if (!hasNamedExport(sourceFile, exportedName)) { + addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) + } + } +} + +function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { + const constants = new Map() + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue + const value = stringValue(declaration.initializer, constants) + if (value !== undefined) constants.set(declaration.name.text, value) + } + } + return constants +} + +function stringValue(node: ts.Expression, constants: ReadonlyMap): string | undefined { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text + if (ts.isIdentifier(node)) return constants.get(node.text) + return undefined +} + +function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean { + return ts.isPropertyAccessExpression(expression) + && expression.name.text === 'register' + && ts.isPropertyAccessExpression(expression.expression) + && expression.expression.name.text === 'invariants' +} + +function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean { + return sourceFile.statements.some((statement) => { + if (!ts.isVariableStatement(statement) + || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false + return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name) + }) +} + +/** Format violations for the command-line gate. */ +export function formatPackageInvariantViolation( + root: string, + violation: PackageInvariantViolation, +): string { + const path = resolve(root, violation.path) + return `${relative(root, path)}: ${violation.message}` +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f46de4475a..8e2a7f961c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -204,6 +204,7 @@ function ciPrimaryGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('typecheck', 'typecheck'), lintGate(), @@ -229,6 +230,7 @@ function ciStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), ...staticDemoSmokeGates(), ...docSyncLeafGates(), @@ -314,6 +316,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('knip', 'knip'), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', ...artifactOptions, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts new file mode 100644 index 0000000000..77150abb74 --- /dev/null +++ b/scripts/test-invariants.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context, Service } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { packageInvariantOwners } from './package-invariants.ts' +import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' + +declare module 'cordis' { + interface Context { + testInvariantProbe: TestInvariantProbe + } +} + +class TestInvariantProbe extends Service { + constructor(ctx: Context) { + super(ctx, 'testInvariantProbe') + } +} + +describe('global test invariant host', () => { + it('loads every companion and reserves every package name with enabled checks', async () => { + const ctx = new Context() + await ctx.plugin(TestInvariantProbe) + + const owners = packageInvariantOwners(process.cwd()) + expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length) + const unreserved: string[] = [] + for (const owner of owners) { + try { + const dispose = ctx.invariants.register(owner.packageName, () => {}) + unreserved.push(owner.packageName) + dispose() + } catch (error) { + expect(error).toHaveProperty( + 'message', + `invariants: package "${owner.packageName}" is already registered`, + ) + } + } + expect(unreserved).toEqual([]) + }) + + it('executes each companion registration with its owning package name', async () => { + const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) + const registrations = new Map() + const register = vi.fn((_packageName: string, installer: InvariantInstaller) => { + expect(typeof installer).toBe('function') + return () => {} + }) + const fakeContext = { invariants: { register } } as unknown as Context + for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { + const path = rawPath.replace(/^\.\.\//, '') + await companion.apply(fakeContext) + const call = register.mock.calls.at(-1) + if (call === undefined) throw new Error(`${path}: companion did not register`) + registrations.set(path, call[0]) + } + expect(registrations).toEqual(owners) + }) + + it('limits manual composition to focused invariant topology tests', () => { + expect(MANUAL_INVARIANT_TESTS).toEqual([ + '/packages/support/invariants/tests/service.spec.ts', + '/packages/core/session/tests/invariant.spec.ts', + '/packages/core/agent/tests/invariant.spec.ts', + '/packages/core/scope/tests/invariant.spec.ts', + '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', + ]) + }) +}) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts new file mode 100644 index 0000000000..584db3d5d7 --- /dev/null +++ b/scripts/test-invariants.ts @@ -0,0 +1,104 @@ +/** + * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant + * service with global enablement and every package companion before their first + * plugin starts. Focused invariant tests own their service topology explicitly. + */ + +import { expect } from 'vitest' +import { RegistryService } from 'cordis' +import type { Context, Plugin } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' + +declare global { + interface ImportMeta { + /** Eager Vite module-glob expansion used by the Vitest setup file. */ + glob(pattern: string, options: { eager: true }): Record + } +} + +/** Loader-safe shape shared by every package invariant companion. */ +export interface TestInvariantCompanion { + readonly name: string + readonly inject: readonly string[] + apply(ctx: Context): Promise<() => void> +} + +/** Every package companion, discovered eagerly so coverage observes each registration. */ +export const testInvariantCompanions: Readonly> = + import.meta.glob('../packages/*/*/src/invariant.ts', { eager: true }) + +/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */ +export const MANUAL_INVARIANT_TESTS = [ + '/packages/support/invariants/tests/service.spec.ts', + '/packages/core/session/tests/invariant.spec.ts', + '/packages/core/agent/tests/invariant.spec.ts', + '/packages/core/scope/tests/invariant.spec.ts', + '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', +] as const + +interface InvariantHost { + readonly fibers: readonly PluginFiber[] + readonly byCallback: ReadonlyMap +} + +type PluginFiber = ReturnType + +const hosts = new WeakMap() +// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly. +const originalPlugin = RegistryService.prototype.plugin + +RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) { + if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack) + + const root = this.ctx.root + const host = hosts.get(root) ?? startInvariantHost(root) + const callback = this.resolve(plugin) + const existing = callback === undefined ? undefined : host.byCallback.get(callback) + if (existing !== undefined) return existing + + const fiber = originalPlugin.call(this, plugin, config, getOuterStack) + // A root-level await is the test's composition boundary. Nested plugin + // fibers must not await their own companion parent through the global host. + if (this.ctx !== root) return fiber + return joinInvariantStartup(fiber, host.fibers) +} + +function usesManualInvariantTree(): boolean { + const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? '' + return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) +} + +function startInvariantHost(root: Context): InvariantHost { + const fibers: PluginFiber[] = [] + const byCallback = new Map() + const mount = (plugin: Plugin, config?: unknown): void => { + const fiber = originalPlugin.call(root.registry, plugin, config) + const callback = root.registry.resolve(plugin) + if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') + fibers.push(fiber) + byCallback.set(callback, fiber) + } + + mount(InvariantService, { enabled: true }) + for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + if (!companion.inject.includes('invariants')) { + throw new Error(`test invariants: ${path} must inject the invariant service`) + } + mount(companion) + } + + const host = { fibers, byCallback } + hosts.set(root, host) + return host +} + +function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { + const readiness = fiber.await().then(async (loaded) => { + await Promise.all(invariantFibers.map(invariant => invariant.await())) + return loaded + }) + const joined = Object.create(fiber) as PluginFiber + joined.then = readiness.then.bind(readiness) + return joined +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 5efebe5b0b..31fa53c675 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,8 +3,9 @@ import { defineConfig } from 'tsdown' /** * JS bundling for vendored Cordis and Harness TypeScript packages. * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown - * reads only the emitted JS under lib/types and writes lib/index.* runtime - * bundles. Declarations are NOT produced here, hence `dts: false`. + * reads only the emitted JS under lib/types and writes the package root and + * invariant companion runtime bundles. Declarations are NOT produced here, + * hence `dts: false`. * * Per-package shape overrides live in `/tsdown.config.ts` * (schemastery: dual ESM+CJS; logger-console: extra browser entry). @@ -14,7 +15,9 @@ export default defineConfig({ // `workspace: true` would discover package manifests outside that bundle set. Landlock // platform packages contain only a prebuilt native binary, so they have no JS entry. workspace: ['vendor/*', 'packages/*/*'], - entry: ['lib/types/index.js'], + // The brace glob admits the package companion when present while retaining the + // index-only build for vendored Cordis packages outside the Harness package tree. + entry: ['lib/types/{index,invariant}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vitest.config.ts b/vitest.config.ts index c0946e2e10..4fdcf012f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ // to source; native resolution would fall through to absent `lib/` outputs. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], coverage: { provider: 'v8', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index adc8b3f3b2..ab22640c79 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index fb2e890eb7..368f04f423 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: [ 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index b87ea93aa7..e55dd664c7 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L94) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L95) ### ctx.invariants.register(packageName, installer) @@ -29,4 +29,4 @@ Register one package's invariant installer. The package name is reserved even wh **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L136) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L137) From 36e99e737b8dfa50fb62bdc9f15dd38bb4da36a2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:24:32 +0800 Subject: [PATCH 15/48] test(sandbox): pack invariant service peer --- packages/sandbox/sandbox-local/tests/packed-install.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 8519357c1b..9fcfe23de8 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', + 'packages/support/invariants', ] /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ From 941b0411d80fba789bdd237946d406593ee13df9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:38:37 +0800 Subject: [PATCH 16/48] feat(invariants): implement package runtime checks --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- docs/rfc/INDEX.md | 1 + ...kage-invariant-runtime-contracts.i18n.yaml | 6 + ...-19-package-invariant-runtime-contracts.md | 65 +++++ ...-package-invariant-runtime-contracts.zh.md | 65 +++++ ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 8 +- ...7-19-package-owned-invariant-service.zh.md | 8 +- package.json | 3 +- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 27 ++- packages/bash/bash-sandbox/src/invariant.ts | 29 ++- packages/bash/bash/src/invariant.ts | 20 +- packages/bash/tool-bash/src/invariant.ts | 33 ++- .../code-runtime-worker/src/invariant.ts | 24 +- .../code-runtime/src/invariant.ts | 28 ++- .../code-runtime/tests/service.spec.ts | 14 ++ .../compact/compact-basic/src/invariant.ts | 45 +++- .../compact-basic/tests/compact-basic.spec.ts | 25 +- packages/compact/compact/src/invariant.ts | 20 +- .../context/time-context/src/invariant.ts | 26 +- .../workspace-context/src/invariant.ts | 26 +- packages/cordis/tool-cordis/src/invariant.ts | 27 ++- packages/core/system-prompt/src/invariant.ts | 27 ++- packages/core/tools/src/invariant.ts | 30 ++- packages/examples/acp-demo/src/invariant.ts | 23 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 9 +- .../agent-spine-demo/src/invariant.ts | 23 +- .../agent-spine-demo/tests/agent-core.spec.ts | 9 +- packages/examples/cli-demo/src/invariant.ts | 23 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 9 +- .../examples/jsonrpc-demo/src/invariant.ts | 21 +- packages/examples/stdio-demo/src/invariant.ts | 23 +- .../stdio-demo/tests/stdio-agent.spec.ts | 9 +- packages/fs/fs-local/src/invariant.ts | 26 +- packages/fs/fs-policy/src/invariant.ts | 25 +- packages/fs/fs/src/invariant.ts | 20 +- packages/fs/tool-fs-search/src/invariant.ts | 28 ++- packages/fs/tool-fs/src/invariant.ts | 28 ++- .../guard/repeat-tool-guard/src/invariant.ts | 24 +- packages/hooks/hook-protocol/src/invariant.ts | 30 ++- packages/hooks/hooks-claude/src/invariant.ts | 39 ++- .../hooks-claude/tests/invariant.spec.ts | 26 ++ packages/hooks/hooks-codex/src/invariant.ts | 37 ++- .../hooks/hooks-codex/tests/invariant.spec.ts | 26 ++ packages/llm/llm-deepseek/src/invariant.ts | 26 +- packages/llm/llm-pi-ai/src/invariant.ts | 26 +- packages/llm/llm/src/invariant.ts | 26 +- packages/llm/token-meter/src/invariant.ts | 27 ++- packages/mcp/mcp-client/src/invariant.ts | 27 ++- .../sandbox/sandbox-local/src/invariant.ts | 26 +- packages/sandbox/sandbox/src/invariant.ts | 20 +- packages/sdk/create-sdk/src/invariant.ts | 32 ++- packages/sdk/helper/src/invariant.ts | 28 ++- packages/sdk/scripts/src/args.ts | 5 +- packages/sdk/scripts/src/forwarding.ts | 21 ++ packages/sdk/scripts/src/invariant.ts | 30 ++- packages/sdk/scripts/tests/scripts.spec.ts | 1 + .../src/invariant.ts | 26 +- .../src/invariant.ts | 26 +- .../session-persistence/src/invariant.ts | 17 +- .../session-query/src/invariant.ts | 29 ++- packages/skill/skill-local/src/invariant.ts | 26 +- packages/skill/skill/src/invariant.ts | 26 +- packages/skill/tool-skill/src/invariant.ts | 28 ++- packages/spill/spill-local/src/invariant.ts | 26 +- packages/spill/spill-policy/src/invariant.ts | 30 ++- .../spill-policy/tests/spill-policy.spec.ts | 11 + packages/spill/spill/src/invariant.ts | 20 +- .../subagent/subagent-acp/src/invariant.ts | 26 +- .../subagent/subagent-fork/src/invariant.ts | 26 +- .../subagent-inprocess/src/invariant.ts | 23 +- .../src/structured-protocol.ts | 10 + .../subagent-inprocess/src/structured.ts | 14 +- .../subagent/subagent-spawn/src/invariant.ts | 26 +- .../subagent-subprocess/src/invariant.ts | 28 ++- packages/subagent/subagent/src/invariant.ts | 26 +- .../subagent/tool-subagent/src/invariant.ts | 28 ++- .../support/acp-snapshot/src/invariant.ts | 33 ++- .../support/agent-loop-testkit/src/index.ts | 18 +- .../agent-loop-testkit/src/invariant.ts | 24 +- packages/support/invariants/README.md | 19 +- packages/support/invariants/src/index.ts | 176 +++++++++++++- packages/support/invariants/src/invariant.ts | 27 ++- .../support/invariants/tests/service.spec.ts | 222 +++++++++++++++++- packages/support/llm-replay/src/invariant.ts | 29 ++- .../support/loader-smoke/src/invariant.ts | 31 ++- packages/tasks/tasks/src/invariant.ts | 27 ++- packages/tasks/tool-tasks/src/invariant.ts | 28 ++- .../timeout/timeout-policy/src/invariant.ts | 26 +- packages/todo/tool-todo/src/invariant.ts | 26 +- packages/ui/acp/src/invariant.ts | 33 ++- packages/ui/app-boot/src/config-path.ts | 23 ++ packages/ui/app-boot/src/index.ts | 21 +- packages/ui/app-boot/src/invariant.ts | 27 ++- packages/ui/jsonrpc/src/invariant.ts | 26 +- packages/ui/permission/src/invariant.ts | 30 ++- .../ui/permission/tests/permission.spec.ts | 7 +- packages/ui/stdio/src/invariant.ts | 28 ++- packages/ui/tool-ask-user/src/invariant.ts | 27 ++- packages/ui/tui/src/invariant.ts | 29 ++- packages/ui/tui/tests/tui.snapshot.ts | 2 +- packages/ui/user-approval/src/invariant.ts | 27 ++- packages/ui/user-interaction/src/invariant.ts | 26 +- packages/util/brand/src/invariant.ts | 21 +- packages/util/home/src/invariant.ts | 26 +- packages/util/paths/src/invariant.ts | 27 ++- packages/util/retention/src/invariant.ts | 32 ++- packages/util/timeout/src/invariant.ts | 27 ++- packages/web/tool-web/src/invariant.ts | 28 ++- packages/web/web-fetch-local/src/invariant.ts | 26 +- .../web/web-search-deepseek/src/invariant.ts | 23 +- packages/web/web-search-exa/src/invariant.ts | 26 +- .../web-search-perplexity/src/invariant.ts | 23 +- packages/web/web/src/invariant.ts | 26 +- .../workflow/tool-workflow/src/invariant.ts | 28 ++- .../workflow-workerthread/src/invariant.ts | 26 +- packages/workflow/workflow/src/invariant.ts | 20 +- scripts/gen-package-invariants.ts | 44 ---- scripts/package-invariants.spec.ts | 112 ++++++++- scripts/package-invariants.ts | 150 ++++++++---- scripts/verify-package-invariants.ts | 21 ++ website/zh-CN/api/harness/invariants.md | 4 +- 125 files changed, 2317 insertions(+), 1161 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md create mode 100644 packages/hooks/hooks-claude/tests/invariant.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/invariant.spec.ts create mode 100644 packages/sdk/scripts/src/forwarding.ts create mode 100644 packages/subagent/subagent-inprocess/src/structured-protocol.ts create mode 100644 packages/ui/app-boot/src/config-path.ts delete mode 100644 scripts/gen-package-invariants.ts create mode 100644 scripts/verify-package-invariants.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ea9c7a300..76663117b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -396,7 +396,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:16`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ca5eb4232b..7da704df75 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -495,7 +495,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:95`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:261`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 29f8763460..b1177e74a5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,6 +55,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/plugin` | - | [`invariants`](../packages/support/invariants) | +| `internal/service` | - | [`invariants`](../packages/support/invariants) | +| `internal/status` | - | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c7e542276e..e4ee5fab8d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -167,6 +167,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | +| [Executable package invariant contracts](implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md) | 2026-07-19 | | [Package-owned invariant service seam](implemented/architecture/2026-07-19-package-owned-invariant-service.md) | 2026-07-19 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml new file mode 100644 index 0000000000..f234facc6e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -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 +2026-07-19-package-invariant-runtime-contracts.md: 57a768f2e3cc954d02f74a9dca46680e59a06403 +2026-07-19-package-invariant-runtime-contracts.zh.md: 7a289da17414cc1fcac3109b4a96b2f047b16a89 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md new file mode 100644 index 0000000000..57a768f2e3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -0,0 +1,65 @@ +# RFC: Executable package invariant contracts + +Status: implemented + +English | [中文](2026-07-19-package-invariant-runtime-contracts.zh.md) + +## Problem + +The package-owned invariant seam made registration and publication exhaustive, but its generated baseline treated package-name ownership as sufficient. An empty installer could satisfy the repository gate while observing no runtime state and rejecting no invalid state. That made the exhaustive count a wiring claim rather than protection for the package contract. + +Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. + +Vitest also mounts every companion globally. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. + +## Decision + +### Every companion executes a package contract + +Every workspace package keeps its separately published `./invariant` companion and exact npm-name registration, but the installer must execute at least one package-specific check through the bound `fail(message)` reporter. The ownership-baseline generator and its root script entry are removed; generated markers, empty installers, and installers that never reference the reporter are repository errors. + +The implemented contracts use four forms: + +| Owner shape | Runtime contract | +|---|---| +| Stateful session, agent, scope, and agent-loop owners | Validate event ordering, enclosure, status transitions, scoped subjects, and reconstructable model requests. | +| Cordis plugin owners | Validate the plugin's own declared runtime name, required injections, owned effects, provided services, and package-specific all-or-none or config-dependent relations. | +| Cordis service seams | Validate the structural method and descriptor surface of current and future implementations. | +| Pure libraries, bins, and support packages | Validate stable parser mapping, protocol precedence, retention and timeout algebra, path resolution, normalization, environment scrubbing, or deliberately empty runtime entrypoints. | + +At implementation time this covers all 90 workspace packages: four stateful companions, 62 plugin-fiber companions, eight service-shape companions, and 16 pure/bin/support companions. + +### Product-independent observers + +`observePluginInvariant` checks existing fibers immediately and future active fibers through global Cordis lifecycle events. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. + +`observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. + +`assertInvariant` handles synchronous package algebra. Pure-package companions register an asynchronous child effect and dynamically import their owner inside that effect. This preserves atomic service-owned rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. + +### Gate and test execution + +`verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. + +The Vitest setup host mounts `InvariantService` with `{ enabled: true }` and all 90 companions before an ordinary Cordis root's first plugin. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. Focused selection, lifecycle, and four stateful-owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. + +Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. + +## Alternatives considered + +- **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. +- **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. +- **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. +- **Import every owner entrypoint statically from its companion.** Rejected because the global test host would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. +- **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. +- **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. + +## Consequences + +- Every package contributes an executable check; adding a package without one fails the top-level gate. +- The invariant service remains product-independent while providing reusable lifecycle and shape observers. +- Ordinary unit, snapshot, and e2e tests run with global invariant enablement and every companion registered. +- Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. +- Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. +- More companion work runs during tests and selected deployments, trading small startup cost for immediate package-attributed failures. +- The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md new file mode 100644 index 0000000000..7a289da174 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -0,0 +1,65 @@ +# RFC: 可执行的包不变式契约 + +Status: implemented + +[English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 + +## 问题 + +包拥有的不变式接缝让注册与发布覆盖完整,但生成的基线把包名所有权视为充分条件。空 installer 可以通过仓库门禁,却不观察任何运行时状态,也不拒绝任何无效状态。这样一来,完整计数只能证明接线存在,不能保护包契约。 + +不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 + +Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 + +## 决策 + +### 每个伴随插件都执行包契约 + +每个工作区包保留独立发布的 `./invariant` 伴随插件和准确 npm 包名注册,但 installer 必须通过绑定的 `fail(message)` 报告器执行至少一个包专属检查。删除所有权基线生成器及其根脚本入口;生成标记、空 installer 和从不引用报告器的 installer 都属于仓库错误。 + +实现后的契约采用四种形态: + +| 所有者形态 | 运行时契约 | +|---|---| +| 有状态的 session、agent、scope 与 agent-loop 所有者 | 验证事件顺序、包围关系、状态转换、作用域主体和可重建的模型请求。 | +| Cordis 插件所有者 | 验证插件自身声明的运行时名称、必要注入、拥有的 effect、提供的服务,以及包专属的全有或全无关系或配置依赖关系。 | +| Cordis 服务接缝 | 验证当前和未来实现的结构化方法与描述字段表面。 | +| 纯库、bin 与支持包 | 验证稳定的解析映射、协议优先级、保留与超时代数、路径解析、规范化、环境清理或刻意为空的运行时入口。 | + +实现时覆盖全部 90 个工作区包:四个有状态伴随插件、62 个插件 fiber 伴随插件、八个服务形状伴随插件和 16 个纯库、bin 或支持包伴随插件。 + +### 与产品无关的观察器 + +`observePluginInvariant` 会立即检查已有 fiber,并通过全局 Cordis 生命周期事件检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 + +`observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 + +`assertInvariant` 处理同步包代数。纯包伴随插件注册异步子 effect,并在该 effect 内动态导入所有者。这样既保留服务拥有的原子回滚,又允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 + +### 门禁与测试执行 + +`verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 + +Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 90 个伴随插件,然后才启动普通 Cordis 根上下文的第一个插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。选择、生命周期和四个有状态所有者的聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 + +辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 + +## 考虑过的替代方案 + +- **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 +- **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 +- **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 +- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为全局测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 +- **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 +- **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 + +## 后果 + +- 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 +- 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 +- 普通单元、snapshot 与 e2e 测试均全局启用不变式并注册每个伴随插件。 +- 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 +- 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 +- 测试和被选部署会执行更多伴随工作,以少量启动成本换取即时且带包归属的失败。 +- 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index dfe64b7110..c3d4aa812a 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: bf27bb4e951988bc124509b49dea9b5509f8e6f3 -2026-07-19-package-owned-invariant-service.zh.md: c9dfbb65bf4fd497f2f592f7741923dcef3e51fb +2026-07-19-package-owned-invariant-service.md: 32a5a0798c4121be01ae1a51ddb4e864a2663de5 +2026-07-19-package-owned-invariant-service.zh.md: 9ebeda7575452fd29245bffb1354e56e596f711e diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md index bf27bb4e95..32a5a0798c 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name and installs an executable package-specific contract. Generated ownership-only installers are forbidden by the follow-up [runtime-contract RFC](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners contain stateful checks and focused tests. Other owners check their plugin fibers and effects, structural service implementations, or stable pure-library algebra. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. +`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, empty or reporter-free installers, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. ### Scoped-event semantic map @@ -96,7 +96,7 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. +- Every package pays the publication, dependency, and runtime-check cost of an executable invariant companion. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index c9dfbb65bf..9ebeda7575 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名,并安装可执行的包专属契约。后续的[运行时契约 RFC](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的仅声明所有权 installer。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者保存有状态检查与聚焦测试。其他所有者检查自己的插件 fiber 与 effect、结构化服务实现或稳定的纯库代数。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、空 installer、不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 ### Scoped event 语义映射 @@ -96,7 +96,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 +- 每个包都要承担可执行不变式伴随插件带来的发布、依赖与运行时检查成本。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 diff --git a/package.json b/package.json index abf1e59178..f62e92f754 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", - "gen-package-invariants": "tsx scripts/gen-package-invariants.ts", - "verify-package-invariants": "tsx scripts/gen-package-invariants.ts --check", + "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1735c44978..b5fcf47273 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant`, register the manifest's exact npm name, and keep the generated ownership baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Every package owns executable invariants.** Publish `./invariant`, register its exact name, and enforce a package runtime contract with the bound reporter; generated, empty, and reporter-free installers fail `verify-package-invariants` ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 8af5f91b22..397bf0d6dc 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-local`. @module @deepseek-ai/dsh-bash-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' @@ -17,8 +10,19 @@ export const name = 'bash-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalBashExecutor', + effects: [ + 'ctx.provide("bash")', + 'local bash teardown', + ], + services: [ + 'bash', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index e74190fa82..f758c90f6f 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-sandbox`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash-sandbox/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-sandbox`. @module @deepseek-ai/dsh-bash-sandbox/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' @@ -17,8 +10,21 @@ export const name = 'bash-sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SandboxBashExecutor', + inject: [ + 'sandbox', + ], + effects: [ + 'ctx.provide("bash")', + ], + services: [ + 'bash', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 350f68bfc7..91f135b1bb 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash`. @module @deepseek-ai/dsh-bash/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' @@ -17,8 +10,12 @@ export const name = 'bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'bash', value => serviceShapeViolation(value, { + methods: ['resolve', 'run', 'start'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 286089c7ff..ca63f9ff77 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-bash`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-bash/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-bash`. @module @deepseek-ai/dsh-tool-bash/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' @@ -17,8 +10,25 @@ export const name = 'tool-bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-bash', + inject: [ + 'tools', + 'bash', + 'systemPrompt', + ], + effects: [ + 'ctx.provide("bashEnv")', + 'bashEnv.register()', + 'tools.register()', + ], + services: [ + 'bashEnv', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +37,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 41b3eab511..63daf58ae9 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime-worker`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime-worker`. * @module @deepseek-ai/dsh-code-runtime-worker/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' @@ -17,8 +13,19 @@ export const name = 'code-runtime-worker-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WorkerCodeRuntime', + effects: [ + 'ctx.provide("codeRuntime")', + 'worker code-runtime teardown', + ], + services: [ + 'codeRuntime', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 6102927d77..1ee564c60b 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-code-runtime/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime`. @module @deepseek-ai/dsh-code-runtime/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' @@ -17,8 +10,20 @@ export const name = 'code-runtime-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'codeRuntime', (value) => { + const violation = serviceShapeViolation(value, { + methods: ['run'], + stringProperties: ['language', 'isolation'], + }) + if (violation !== undefined) return violation + const service = value as { language: string; isolation: string } + return /^[a-z][a-z0-9-]*$/.test(service.language) && /^[a-z][a-z0-9-]*$/.test(service.isolation) + ? undefined + : 'code runtime language and isolation must be lowercase identifiers' + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 7811ef0531..01736ea0ab 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -84,4 +84,18 @@ describe('CodeRuntime service seam', () => { const { ctx } = await setup() await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) }) + + it.each([ + [{ language: 'typescript', isolation: 'worker' }, /must expose method "run"/], + [{ language: 'TypeScript', isolation: 'worker', run() {} }, /must be lowercase identifiers/], + ])('rejects an invalid runtime implementation through the package invariant', async (value, message) => { + const ctx = new Context() + const invalidRuntime = { + name: 'invalid-code-runtime', + apply(child: Context) { + child.provide('codeRuntime', value as unknown as CodeRuntime) + }, + } + await expect(ctx.plugin(invalidRuntime)).rejects.toThrow(message) + }) }) diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index f72c0e5898..3e034a2f58 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-compact-basic`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-compact-basic/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact-basic`. @module @deepseek-ai/dsh-compact-basic/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' @@ -17,8 +10,37 @@ export const name = 'compact-basic-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'BasicCompactService', + inject: [ + 'llm', + 'tokenMeter', + ], + effects: [ + 'ctx.provide("compact")', + ], + services: [ + 'compact', + ], + validate: (fiber, effectLabels) => { + const automaticEffects = [ + 'ctx.on("agent/post-step")', + 'ctx.on("agent/request-error")', + ] + const installed = automaticEffects.filter(label => effectLabels.has(label)).length + const automatic = (fiber.config as { auto?: boolean }).auto !== false + if (automatic && installed !== automaticEffects.length) { + return 'automatic compaction must install both pressure and overflow listeners' + } + if (!automatic && installed !== 0) { + return 'auto:false must install neither automatic compaction listener' + } + return undefined + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +49,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 31992793f0..762289c9d9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -5,7 +5,7 @@ import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CompactService, CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -198,6 +198,29 @@ describe('compact configuration and defaults', () => { expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) } }) + + it.each([ + [{ auto: true }, false, /must install both pressure and overflow listeners/], + [{ auto: false }, true, /must install neither automatic compaction listener/], + ])('rejects an inconsistent automatic-listener topology through the package invariant', async (config, installListener, message) => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(TokenMeterService) + const invalidCompact = { + name: 'BasicCompactService', + inject: ['llm', 'tokenMeter'], + apply(child: Context, _config: { auto?: boolean }) { + child.provide('compact', { + compactIfNeeded() {}, + compactRegion() {}, + } as unknown as CompactService) + if (installListener) { + child.effect(() => () => {}, 'ctx.on("agent/post-step")') + } + }, + } + await expect(ctx.plugin(invalidCompact, config)).rejects.toThrow(message) + }) }) describe('pressure measurement and retention', () => { diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index f7a2cbffd1..974f7a29ca 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-compact`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-compact/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact`. @module @deepseek-ai/dsh-compact/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' @@ -17,8 +10,12 @@ export const name = 'compact-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'compact', value => serviceShapeViolation(value, { + methods: ['compactIfNeeded', 'compactRegion'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 64fb98ac81..f1497f4c4a 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-time-context`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-time-context/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-time-context`. @module @deepseek-ai/dsh-time-context/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' @@ -17,8 +10,18 @@ export const name = 'time-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'time-context', + inject: [ + 'agents', + ], + effects: [ + 'ctx.on("agent/pre-step")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f3e76225c0..0258acff14 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workspace-context`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-workspace-context/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workspace-context`. @module @deepseek-ai/dsh-workspace-context/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' @@ -17,8 +10,18 @@ export const name = 'workspace-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'workspace-context', + effects: [ + 'ctx.on("session/event")', + 'ctx.on("agent/session-prefix")', + 'ctx.on("tools/post-execute")', + 'ctx.on("tools/result")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index a3f375b69f..8d09b3bb9e 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-cordis`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-cordis/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-cordis`. @module @deepseek-ai/dsh-tool-cordis/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' @@ -17,8 +10,19 @@ export const name = 'tool-cordis-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-cordis', + inject: [ + 'tools', + ], + effects: [ + 'ctx.plugin()', + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 5117f93ce0..36052bb90a 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-system-prompt`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-system-prompt/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-system-prompt`. @module @deepseek-ai/dsh-system-prompt/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' @@ -17,8 +10,19 @@ export const name = 'system-prompt-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SystemPrompt', + effects: [ + 'ctx.provide("systemPrompt")', + 'systemPrompt.section()', + ], + services: [ + 'systemPrompt', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 7c5743e1d4..c5b53e67fa 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tools`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tools/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tools`. @module @deepseek-ai/dsh-tools/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tools' @@ -17,8 +10,22 @@ export const name = 'tools-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ToolRegistry', + inject: [ + 'systemPrompt', + ], + effects: [ + 'ctx.provide("tools")', + 'systemPrompt.tools()', + ], + services: [ + 'tools', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index d8f2ff17dc..b8801dd71e 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp-demo`. @module @deepseek-ai/dsh-acp-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' @@ -17,8 +10,15 @@ export const name = 'acp-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'acp-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 10ee749f8c..d8d6f85db5 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -21,7 +21,14 @@ import * as acpAgent from '../src/index.ts' */ async function mount(config: acpAgent.Config, withBash = false): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } await ctx.plugin(acpAgent, config) // The bundle mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services are ready. diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 913b5ca2ab..75ab162567 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-spine-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-agent-spine-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-agent-spine-demo`. @module @deepseek-ai/dsh-agent-spine-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' @@ -17,8 +10,15 @@ export const name = 'agent-spine-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'agent-spine-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 555a675cba..37bde87b31 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -48,7 +48,14 @@ async function mount(config: agentCore.Config, withBash = false): Promise {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'cli-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 343d6184d7..4e55f387fa 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -22,7 +22,14 @@ async function skillConfig(catalogDescriptionMaxLength?: number): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } contexts.push(ctx) await ctx.plugin(cliDemo, config) await new Promise(resolve => setTimeout(resolve, 80)) diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index 21f19faf0f..f65eee8165 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-jsonrpc-demo/invariant - */ +/** Package-owned runtime contract for @deepseek-ai/dsh-jsonrpc-demo. @module @deepseek-ai/dsh-jsonrpc-demo/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' @@ -17,8 +11,15 @@ export const name = 'jsonrpc-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') + return () => {} + }, 'jsonrpc-demo: validate bin-only entrypoint') +} /** * Register this package's invariant companion. diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 5d51c3cd9b..8e24a3b94b 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio-demo`. @module @deepseek-ai/dsh-stdio-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' @@ -17,8 +10,15 @@ export const name = 'stdio-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'stdio-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index c8ca063151..7bd309a916 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -18,7 +18,14 @@ import * as stdioAgent from '../src/index.ts' */ async function mount(config: stdioAgent.Config, withBash = false): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } await ctx.plugin(stdioAgent, config) // The app mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services + the pre-created agent are ready. diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 0c89f2299b..6469bffae6 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-local`. @module @deepseek-ai/dsh-fs-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' @@ -17,8 +10,18 @@ export const name = 'fs-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalFileSystem', + effects: [ + 'ctx.provide("fs")', + ], + services: [ + 'fs', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 11f155d2ec..de77ac303d 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-policy`. @module @deepseek-ai/dsh-fs-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' @@ -17,8 +10,17 @@ export const name = 'fs-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'fs-policy', + effects: [ + 'ctx.on("fs/write-intent")', + 'ctx.on("fs/edit-intent")', + 'ctx.on("fs/observed")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +29,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 55895b4b24..586f962247 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs`. @module @deepseek-ai/dsh-fs/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs' @@ -17,8 +10,12 @@ export const name = 'fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'fs', value => serviceShapeViolation(value, { + methods: ['resolve', 'stat', 'lstat', 'readText', 'streamText', 'listDir', 'writeText', 'editText'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f0055b611a..65da611560 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs-search`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-fs-search/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs-search`. @module @deepseek-ai/dsh-tool-fs-search/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' @@ -17,8 +10,20 @@ export const name = 'tool-fs-search-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-fs-search', + inject: [ + 'tools', + 'systemPrompt', + 'bash', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index 7a683d978f..caa31a13b5 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-fs/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs`. @module @deepseek-ai/dsh-tool-fs/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' @@ -17,8 +10,20 @@ export const name = 'tool-fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-fs', + inject: [ + 'tools', + 'fs', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 05c4df1a66..6bbdd1b539 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-repeat-tool-guard`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-repeat-tool-guard/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-repeat-tool-guard`. @module @deepseek-ai/dsh-repeat-tool-guard/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' @@ -17,8 +10,16 @@ export const name = 'repeat-tool-guard-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'repeat-tool-guard', + effects: [ + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/prompt-submit")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +28,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 9893493644..6503791f1c 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hook-protocol`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hook-protocol/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-hook-protocol. @module @deepseek-ai/dsh-hook-protocol/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' @@ -17,8 +11,24 @@ export const name = 'hook-protocol-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert blocking-exit decoding and restrictive merge precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { parseHookOutput } = await import('./codec.ts') + const { mergeHookOutputs } = await import('./merge.ts') + const blocked = parseHookOutput(2, '', ' denied ') + assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', + 'exit 2 must decode as a block whose reason is trimmed stderr') + + const merged = mergeHookOutputs([ + { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, + { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, + ]) + assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', + 'deny must override allow and retain only the winning decision reason') + return () => {} + }, 'hook-protocol: validate decode and merge algebra') +} /** * Register this package's invariant companion. diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index 5c6002f7f5..d092d78a00 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-claude`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hooks-claude/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-claude`. @module @deepseek-ai/dsh-hooks-claude/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' @@ -17,8 +10,31 @@ export const name = 'hooks-claude-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'hooks-claude', + inject: [ + 'bash', + ], + validate: (_fiber, effectLabels) => { + const hookEffects = [ + 'hooks-claude: drain detached hook runs', + 'ctx.on("agent/session-start")', + 'ctx.on("agent/prompt-submit")', + 'ctx.on("tools/pre-execute")', + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/turn-continuation")', + 'ctx.on("subagent/start")', + 'ctx.on("subagent/end")', + ] + const installed = hookEffects.filter(label => effectLabels.has(label)).length + return installed === 0 || installed === hookEffects.length + ? undefined + : 'a readable Claude hook config must install its complete listener set atomically' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +43,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/invariant.spec.ts b/packages/hooks/hooks-claude/tests/invariant.spec.ts new file mode 100644 index 0000000000..1f7513cc64 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/invariant.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' + +describe('Claude hook package invariant', () => { + it('rejects a partially installed hook listener set', async () => { + const ctx = new Context() + await ctx.plugin({ + name: 'claude-invariant-bash', + apply(child: Context) { + child.provide('bash', { + resolve() {}, + async run() {}, + start() {}, + } as unknown as BashExecutor) + }, + }) + await expect(ctx.plugin({ + name: 'hooks-claude', + inject: ['bash'], + apply(child: Context) { + child.effect(() => () => {}, 'ctx.on("agent/session-start")') + }, + })).rejects.toThrow(/must install its complete listener set atomically/) + }) +}) diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 1b8f03a057..4fba6cea2d 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-codex`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hooks-codex/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-codex`. @module @deepseek-ai/dsh-hooks-codex/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' @@ -17,8 +10,29 @@ export const name = 'hooks-codex-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'hooks-codex', + inject: [ + 'bash', + ], + validate: (_fiber, effectLabels) => { + const hookEffects = [ + 'hooks-codex: drain detached hook runs', + 'ctx.on("agent/session-start")', + 'ctx.on("agent/prompt-submit")', + 'ctx.on("tools/pre-execute")', + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/turn-continuation")', + ] + const installed = hookEffects.filter(label => effectLabels.has(label)).length + return installed === 0 || installed === hookEffects.length + ? undefined + : 'a readable Codex hook config must install its complete listener set atomically' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +41,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tests/invariant.spec.ts b/packages/hooks/hooks-codex/tests/invariant.spec.ts new file mode 100644 index 0000000000..bd6ee595f9 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/invariant.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' + +describe('Codex hook package invariant', () => { + it('rejects a partially installed hook listener set', async () => { + const ctx = new Context() + await ctx.plugin({ + name: 'codex-invariant-bash', + apply(child: Context) { + child.provide('bash', { + resolve() {}, + async run() {}, + start() {}, + } as unknown as BashExecutor) + }, + }) + await expect(ctx.plugin({ + name: 'hooks-codex', + inject: ['bash'], + apply(child: Context) { + child.effect(() => () => {}, 'ctx.on("agent/session-start")') + }, + })).rejects.toThrow(/must install its complete listener set atomically/) + }) +}) diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index c3e6b5e153..3327cec448 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-deepseek`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-deepseek/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-deepseek`. @module @deepseek-ai/dsh-llm-deepseek/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' @@ -17,8 +10,18 @@ export const name = 'llm-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-deepseek', + inject: [ + 'llm', + ], + effects: [ + 'llm.registerAdapter()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index 4ada7a1a51..ba546fb902 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-pi-ai`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-pi-ai/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-pi-ai`. @module @deepseek-ai/dsh-llm-pi-ai/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' @@ -17,8 +10,18 @@ export const name = 'llm-pi-ai-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-pi-ai', + inject: [ + 'llm', + ], + effects: [ + 'llm.registerAdapter()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a8a56ce245..a4b8b50a3f 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm`. @module @deepseek-ai/dsh-llm/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm' @@ -17,8 +10,18 @@ export const name = 'llm-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LlmService', + effects: [ + 'ctx.provide("llm")', + ], + services: [ + 'llm', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 00ceb567be..838a9409e8 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-token-meter`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-token-meter/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-token-meter`. @module @deepseek-ai/dsh-token-meter/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' @@ -17,8 +10,19 @@ export const name = 'token-meter-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'TokenMeterService', + effects: [ + 'ctx.provide("tokenMeter")', + 'ctx.on("session/event")', + ], + services: [ + 'tokenMeter', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index d9e75e9955..3cb9dd6005 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-mcp-client`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-mcp-client/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-mcp-client`. @module @deepseek-ai/dsh-mcp-client/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' @@ -17,8 +10,19 @@ export const name = 'mcp-client-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'mcp-client', + inject: [ + 'tools', + ], + effects: [ + 'mcp-client.serverName', + 'mcp-client.connection', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index 3582962f94..da6897f755 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-sandbox-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox-local`. @module @deepseek-ai/dsh-sandbox-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' @@ -17,8 +10,18 @@ export const name = 'sandbox-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalSandboxProvider', + effects: [ + 'ctx.provide("sandbox")', + ], + services: [ + 'sandbox', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index b220f11717..cd1f767df0 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-sandbox/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox`. @module @deepseek-ai/dsh-sandbox/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' @@ -17,8 +10,12 @@ export const name = 'sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'sandbox', value => serviceShapeViolation(value, { + methods: ['confine'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 87a697e438..66a95e6a38 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/create-sdk`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/create-sdk/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/create-sdk. @module @deepseek-ai/create-sdk/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' @@ -17,8 +11,26 @@ export const name = 'create-sdk-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the bin-only entrypoint and its core argument mapping. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { parseCreateArgs } = await import('./args.ts') + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the create-sdk library entrypoint must remain empty because the package is bin-only') + const parsed = parseCreateArgs([ + 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', + ]) + assertInvariant(fail, + parsed.directory === 'workspace' + && parsed.provider === 'custom' + && parsed.baseURL === 'https://example.test' + && parsed.runInterface === 'embed' + && parsed.install === false, + 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') + return () => {} + }, 'create-sdk: validate bin and argument contracts') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 63a6fc2055..e91fb012d5 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-helper`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-helper/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-helper. @module @deepseek-ai/dsh-helper/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' @@ -17,8 +11,22 @@ export const name = 'helper-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert FeatureId's zero-cost representation and boundary validation. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { featureId } = await import('./ids.ts') + assertInvariant(fail, featureId('local-plugin') === 'local-plugin', + 'a valid feature id must preserve its runtime string value') + let rejected = false + try { + featureId('Invalid Feature') + } catch (error) { + rejected = error instanceof Error + } + assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') + return () => {} + }, 'dsh-helper: validate feature identities') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 1b91269592..a1f0a2eb0d 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -6,6 +6,7 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' +import { splitForwardedArgs } from './forwarding.ts' /** Commands implemented by the dsh-sdk launcher. */ type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' @@ -33,9 +34,7 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { return { forwarded: [], help: true } } - const separator = argv.indexOf('--') - const launcherArgv = separator === -1 ? argv : argv.slice(0, separator) - const passthrough = separator === -1 ? [] : argv.slice(separator + 1) + const { launcher: launcherArgv, forwarded: passthrough } = splitForwardedArgs(argv) let parsed: DshSdkArgs | undefined const program = new Command() .name('dsh-sdk') diff --git a/packages/sdk/scripts/src/forwarding.ts b/packages/sdk/scripts/src/forwarding.ts new file mode 100644 index 0000000000..56fa85f564 --- /dev/null +++ b/packages/sdk/scripts/src/forwarding.ts @@ -0,0 +1,21 @@ +/** Argument-delimiter handling shared by the SDK launcher and its invariant. */ + +/** Launcher-owned arguments and opaque arguments following `--`. */ +export interface ForwardedArgumentSplit { + /** Arguments parsed by the SDK launcher. */ + readonly launcher: readonly string[] + /** Arguments passed unchanged to the selected project command. */ + readonly forwarded: readonly string[] +} + +/** + * Split the first `--` delimiter without interpreting either side. + * @param argv - complete user argument vector. + * @returns launcher arguments and post-delimiter arguments. + */ +export function splitForwardedArgs(argv: readonly string[]): ForwardedArgumentSplit { + const separator = argv.indexOf('--') + return separator === -1 + ? { launcher: argv, forwarded: [] } + : { launcher: argv.slice(0, separator), forwarded: argv.slice(separator + 1) } +} diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index fd0a0ef55b..316e7ebed1 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-scripts`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-scripts/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-scripts. @module @deepseek-ai/dsh-scripts/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' @@ -17,8 +11,24 @@ export const name = 'scripts-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the launcher's opaque post-separator forwarding boundary. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { splitForwardedArgs } = await import('./forwarding.ts') + const plain = splitForwardedArgs(['dev', 'src/index.ts']) + const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) + assertInvariant(fail, + plain.launcher.length === 2 + && plain.forwarded.length === 0 + && separated.launcher.length === 2 + && separated.launcher[1] === 'src/index.ts' + && separated.forwarded.length === 2 + && separated.forwarded[0] === '--inspect' + && separated.forwarded[1] === '9229', + 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') + return () => {} + }, 'dsh-sdk: validate command argument contracts') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8d110799ea..6f1af04a4e 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -132,6 +132,7 @@ describe('Commander launcher arguments', () => { expect(parseDshSdkArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false }) expect(parseDshSdkArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' }) expect(parseDshSdkArgs(['-h'])).toMatchObject({ help: true }) + expect(parseDshSdkArgs(['--help'])).toMatchObject({ help: true }) expect(() => parseDshSdkArgs(['unknown'])).toThrow() expect(() => parseDshSdkArgs(['config', 'extra'])).toThrow() expect(() => parseDshSdkArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded') diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index 12c65db1c4..a3f3854cff 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-jsonl`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-jsonl`. * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' @@ -17,8 +13,21 @@ export const name = 'session-persistence-jsonl-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionPersistenceJsonl', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionPersistence")', + ], + services: [ + 'sessionPersistence', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index a9b04cf5f5..8eb41c061b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-sqlite`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-sqlite`. * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' @@ -17,8 +13,21 @@ export const name = 'session-persistence-sqlite-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionPersistenceSqlite', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionPersistence")', + ], + services: [ + 'sessionPersistence', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index cc7cc8fa2c..c62805d300 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence`. * @module @deepseek-ai/dsh-session-persistence/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' @@ -17,8 +13,12 @@ export const name = 'session-persistence-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'sessionPersistence', value => serviceShapeViolation(value, { + methods: ['locate', 'create', 'append', 'load', 'list'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 91bcee721e..0f7a93acde 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-query`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-session-query/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-session-query`. @module @deepseek-ai/dsh-session-query/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' @@ -17,8 +10,21 @@ export const name = 'session-query-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionQueryService', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionQuery")', + ], + services: [ + 'sessionQuery', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 475d02bb8f..7afa8c5023 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-skill-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-skill-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill-local`. @module @deepseek-ai/dsh-skill-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' @@ -17,8 +10,18 @@ export const name = 'skill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'skill-local', + inject: [ + 'skills', + ], + effects: [ + 'skills.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index c1d4091bb2..6a673db92a 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-skill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-skill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill`. @module @deepseek-ai/dsh-skill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' @@ -17,8 +10,18 @@ export const name = 'skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SkillService', + effects: [ + 'ctx.provide("skills")', + ], + services: [ + 'skills', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index abf4ba3961..8e6d6bbc3c 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-skill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-skill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-skill`. @module @deepseek-ai/dsh-tool-skill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' @@ -17,8 +10,20 @@ export const name = 'tool-skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-skill', + inject: [ + 'tools', + 'skills', + ], + effects: [ + 'tools.register()', + 'ctx.on("agent/session-prefix")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index d638ffa2a9..b769007d7d 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-local`. @module @deepseek-ai/dsh-spill-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' @@ -17,8 +10,18 @@ export const name = 'spill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalSpillStore', + effects: [ + 'ctx.provide("spillStore")', + ], + services: [ + 'spillStore', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index d4aa544ecd..4af3a81f70 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-policy`. @module @deepseek-ai/dsh-spill-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' @@ -17,8 +10,22 @@ export const name = 'spill-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'spill-policy', + inject: [ + 'tools', + ], + validate: (fiber, effectLabels) => { + const installed = effectLabels.has('ctx.on("tools/post-execute")') + const enabled = (fiber.config as { maxInlineBytes?: number }).maxInlineBytes !== undefined + return installed === enabled + ? undefined + : 'the post-execute spill policy listener must exist exactly when maxInlineBytes is configured' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..f0cbbbd2a0 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -109,6 +109,17 @@ describe('config validation', () => { it('rejects a fractional maxInlineBytes at load', async () => { await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) }) + + it('rejects a configured policy that omits its post-execute listener', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await expect(ctx.plugin({ + name: 'spill-policy', + inject: ['tools'], + apply(_child: Context, _config: { maxInlineBytes?: number }) {}, + }, { maxInlineBytes: 10 })).rejects.toThrow(/listener must exist exactly when maxInlineBytes is configured/) + }) }) describe('oversized plain-text replacement', () => { diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 714e43f3a6..ba971b8bc2 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill`. @module @deepseek-ai/dsh-spill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' @@ -17,8 +10,12 @@ export const name = 'spill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'spillStore', value => serviceShapeViolation(value, { + methods: ['saveText'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index a5828fd4c1..0c17843ff7 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-acp`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-acp/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-acp`. @module @deepseek-ai/dsh-subagent-acp/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' @@ -17,8 +10,18 @@ export const name = 'subagent-acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-acp', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index c6903fd82d..2fc8a0f696 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-fork`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-fork/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-fork`. @module @deepseek-ai/dsh-subagent-fork/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' @@ -17,8 +10,18 @@ export const name = 'subagent-fork-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-fork', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 0eac204104..a007200d73 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-inprocess`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-inprocess/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-inprocess. @module @deepseek-ai/dsh-subagent-inprocess/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' @@ -17,8 +11,17 @@ export const name = 'subagent-inprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that structured-output guidance names the tool it actually installs. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') + assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), + 'the structured-output tool must retain a stable lowercase protocol name') + assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), + 'the structured-output instruction must name the exact installed tool') + return () => {} + }, 'subagent-inprocess: validate structured-output protocol') +} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent-inprocess/src/structured-protocol.ts b/packages/subagent/subagent-inprocess/src/structured-protocol.ts new file mode 100644 index 0000000000..8ae87e73b0 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/structured-protocol.ts @@ -0,0 +1,10 @@ +/** Model-facing constants shared by structured child execution and its invariant. */ + +/** The model-facing tool name a structured child must call to finish. */ +export const STRUCTURED_OUTPUT_TOOL = 'structured_output' + +/** The terminal structured-result instruction appended to a child request. */ +export const STRUCTURED_OUTPUT_INSTRUCTION + = 'When you have your final answer, you MUST report it by calling the ' + + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + + 'Do not finish with a plain text answer: only the tool call counts as your result.' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 09aa2d24b7..cecb7d173e 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -15,19 +15,9 @@ import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' -/** The model-facing tool name a structured child must call to finish. */ -export const STRUCTURED_OUTPUT_TOOL = 'structured_output' - -/** - * The instruction registered as the child's trailing (order-190, the end of - * the tool-guidance band) scoped prompt section: the demand travels with the - * tool, as ordinary prompt state of exactly one agent. - */ -export const STRUCTURED_OUTPUT_INSTRUCTION - = 'When you have your final answer, you MUST report it by calling the ' - + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` - + 'Do not finish with a plain text answer: only the tool call counts as your result.' +export { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' /** One structured run's live handle: read the captured value once the child settles. */ export interface StructuredAttachment { diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index d179a2b72a..28201593e4 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-spawn`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-spawn/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-spawn`. @module @deepseek-ai/dsh-subagent-spawn/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' @@ -17,8 +10,18 @@ export const name = 'subagent-spawn-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-spawn', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 22dac32814..ed29ecc855 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -1,24 +1,32 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-subprocess`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-subprocess/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-subprocess. @module @deepseek-ai/dsh-subagent-subprocess/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** Cordis companion plugin name. */ export const name = 'subagent-subprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert ambient credential scrubbing and explicit credential precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { buildChildEnv } = await import('./index.ts') + const scrubbed = buildChildEnv({}) + const ambientSensitiveNames = Object.keys(process.env).filter(key => SENSITIVE_ENV_PATTERN.test(key)) + assertInvariant(fail, ambientSensitiveNames.every(key => !Object.hasOwn(scrubbed, key)), + 'subprocess environments must omit every credential-shaped ambient variable') + + const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) + assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', + 'explicit child credentials must be applied after ambient scrubbing') + return () => {} + }, 'subagent-subprocess: validate child environment isolation') +} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3a79592ee1..3217a3dd9a 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent`. @module @deepseek-ai/dsh-subagent/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' @@ -17,8 +10,18 @@ export const name = 'subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SubagentService', + effects: [ + 'ctx.provide("subagents")', + ], + services: [ + 'subagents', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 08881e4b2b..b1bfa209a1 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-subagent`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-subagent/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-subagent`. @module @deepseek-ai/dsh-tool-subagent/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -17,8 +10,20 @@ export const name = 'tool-subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-subagent', + inject: [ + 'tools', + 'subagents', + ], + effects: [ + 'ctx.on("subagent/provider-added")', + 'ctx.on("subagent/provider-removed")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index 3579b96cf5..fc568d7b37 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-snapshot`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp-snapshot/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-acp-snapshot. @module @deepseek-ai/dsh-acp-snapshot/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' @@ -17,8 +11,27 @@ export const name = 'acp-snapshot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert stable JSON-RPC correlation and volatile-value tokenization. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { normalizeStdout } = await import('./normalize.ts') + const sessionId = '12345678-1234-1234-1234-123456789abc' + const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } + const raw = [ + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), + ].join('\n') + const normalized = normalizeStdout(raw, volatile) + assertInvariant(fail, + normalized.includes('"id":1') + && normalized.includes('"cwd":"{{cwd}}"') + && normalized.includes('"sessionId":"{{sessionId}}"'), + 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') + assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, + 'ACP stdout normalization must be idempotent') + return () => {} + }, 'acp-snapshot: validate stable transcript normalization') +} /** * Register this package's invariant companion. diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index c7b0cb7304..ca7148ee23 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -6,12 +6,7 @@ */ import type { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config as ToolRegistryConfig } from '@deepseek-ai/dsh-tools' /** Configuration forwarded to the prerequisite service plugins. */ @@ -38,6 +33,19 @@ export async function mountAgentLoopTestDependencies( ctx: Context, options: AgentLoopTestDependenciesOptions = {}, ): Promise { + const [ + { default: LlmService }, + { default: SessionStore }, + { default: SystemPrompt }, + { default: ToolRegistry }, + { default: AgentRegistry }, + ] = await Promise.all([ + import('@deepseek-ai/dsh-llm'), + import('@deepseek-ai/dsh-session'), + import('@deepseek-ai/dsh-system-prompt'), + import('@deepseek-ai/dsh-tools'), + import('@deepseek-ai/dsh-agent'), + ]) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fc2554aa77..fd72038d3f 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-loop-testkit`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-agent-loop-testkit/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-agent-loop-testkit. @module @deepseek-ai/dsh-agent-loop-testkit/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' @@ -17,8 +11,18 @@ export const name = 'agent-loop-testkit-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the awaitable helper shape and optional-options call boundary. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { mountAgentLoopTestDependencies } = await import('./index.ts') + assertInvariant(fail, + mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', + 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') + assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, + 'the prerequisite mount helper must keep its options argument optional') + return () => {} + }, 'agent-loop-testkit: validate prerequisite mount boundary') +} /** * Register this package's invariant companion. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index c49771ded9..74e8b34310 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -24,9 +24,17 @@ The service owns every registration fiber, while the returned disposer also belo ## Package companions -An ownership-only generated baseline installs no listeners but still reserves its package name through the real service boundary. A package replaces that marked file when it gains a relational check, retaining the same registration. `pnpm run verify-package-invariants` checks every package's source registration, export, published files, dependencies, TypeScript reference, and bundle entry. +Every companion installs at least one executable, package-specific contract and reports failure through its bound reporter. There is no generated or ownership-only baseline. `pnpm run verify-package-invariants` rejects generated markers, empty installers, installers that ignore the reporter, duplicate name-based plugin observers, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. -Four companions currently install stateful checks: +Packages select the narrowest runtime form that protects their public contract: + +| Package shape | Companion check | +|---|---| +| Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | +| Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | +| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape in a child effect. | + +Four companions additionally install stateful event and request checks: | Companion | Registration | Checks | |---|---|---| @@ -35,7 +43,7 @@ Four companions currently install stateful checks: | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | -The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. Name-based plugin observers match only a fiber's own declared runtime name, not anonymous child fibers that inherit a parent display name. They avoid importing the product entrypoint before it is loaded; pure-library checks likewise defer owner imports into the installer child so Vitest mocks and deployment loaders establish their module boundary first. ## Composition @@ -54,7 +62,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so all package checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience @@ -62,6 +70,7 @@ None, as the service and companions observe runtime events and requests but neve ## Known Limitations and Deferred Work -- Stateful checks cover only the four listed package contracts; other companions reserve ownership but add no listeners until their packages gain relational assertions. +- A name-based plugin observer assumes Cordis plugin names are unique within one root; a package can provide the exact callback when importing it does not preload an unrelated runtime. +- Pure-library contracts are sampled when their companion child activates rather than observed continuously; mutable package behavior belongs on an event, service, or plugin-fiber observer. - Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 4bbf218e97..374012fdbd 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,14 +1,13 @@ /** * Configurable registry for package-owned runtime invariant contributions. - * Every workspace package registers its name from a `./invariant` companion; - * ordinary package entrypoints stay independent of diagnostics, and packages - * without relational checks use an ownership-only installer. + * Every workspace package registers checks from a `./invariant` companion; + * ordinary package entrypoints stay independent of diagnostics. * * @module @deepseek-ai/dsh-invariants */ -import { Context, Service } from 'cordis' -import type { Inject } from 'cordis' +import { Context, FiberState, Service } from 'cordis' +import type { Fiber, Inject, Plugin } from 'cordis' import z from 'schemastery' import type Schema from 'schemastery' @@ -42,6 +41,173 @@ export interface InvariantInstaller { readonly inject?: Inject } +/** Runtime facts one package expects from its Cordis plugin fiber. */ +export interface PluginInvariantContract { + /** Exact plugin value when checking it does not preload an unrelated runtime; otherwise matching uses `name`. */ + readonly plugin?: Plugin + /** Exact Cordis display name for the plugin fiber. */ + readonly name: string + /** Required service injections that must be present when the fiber activates. */ + readonly inject?: readonly string[] + /** Required owned effect labels; an inner array means at least one alternative must exist. */ + readonly effects?: readonly (string | readonly string[])[] + /** Services the active fiber must provide. */ + readonly services?: readonly string[] + /** Optional package-owned validation after the structural checks pass. */ + readonly validate?: (fiber: Fiber, effectLabels: ReadonlySet) => string | undefined +} + +/** Collect all live effect labels below a plugin fiber. */ +function collectEffectLabels(fiber: Fiber): ReadonlySet { + const labels = new Set() + const visit = (effects: ReturnType): void => { + for (const effect of effects) { + labels.add(effect.label) + visit(effect.children) + } + } + visit(fiber.getEffects()) + return labels +} + +/** + * Observe one package plugin and fail whenever an active fiber violates its + * declared name, dependency, effect, service, or package-specific contract. + * Existing fibers are checked immediately; later starts and HMR activations + * are checked through Cordis lifecycle events. + * @param ctx - invariant child context that owns the observers. + * @param fail - reporter bound to the package that owns the plugin. + * @param contract - expected runtime facts for the package plugin. + * @returns nothing after lifecycle observers are installed. + */ +export function observePluginInvariant( + ctx: Context, + fail: InvariantFailure, + contract: PluginInvariantContract, +): void { + const callback = contract.plugin === undefined ? undefined : ctx.registry.resolve(contract.plugin) + if (contract.plugin !== undefined && callback === undefined) { + fail('invariant contract does not identify a Cordis plugin') + } + + const inspect = (fiber: Fiber): void => { + const matches = callback === undefined + ? fiber.runtime?.name === contract.name + : fiber.runtime?.callback === callback + if (!matches || fiber.state !== FiberState.ACTIVE) return + if (callback !== undefined && fiber.name !== contract.name) { + fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) + } + const injections = new Set(Object.keys(fiber.inject)) + for (const service of contract.inject ?? []) { + if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) + } + + const effectLabels = collectEffectLabels(fiber) + for (const requirement of contract.effects ?? []) { + const alternatives = typeof requirement === 'string' ? [requirement] : requirement + if (!alternatives.some(label => effectLabels.has(label))) { + fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) + } + } + for (const service of contract.services ?? []) { + const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { + const implementation = fiber.ctx.reflect.store[key as symbol] + return implementation?.fiber === fiber && implementation.name === service + }) + if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) + } + const message = contract.validate?.(fiber, effectLabels) + if (message !== undefined) fail(message) + } + + if (contract.plugin === undefined) { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) inspect(fiber) + } + } else { + for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) inspect(fiber) + } + ctx.on('internal/plugin', inspect, { global: true }) + ctx.on('internal/status', inspect, { global: true }) +} + +/** + * Validate every current and future implementation bound to one Cordis service. + * @param ctx - invariant child context that owns the service observer. + * @param fail - reporter bound to the package that owns the service seam. + * @param serviceName - Cordis service name to observe. + * @param validate - returns the violated contract, or `undefined` for a valid implementation. + * @returns nothing after the current binding is checked and the observer is installed. + */ +export function observeServiceInvariant( + ctx: Context, + fail: InvariantFailure, + serviceName: string, + validate: (value: unknown) => string | undefined, +): void { + const inspect = (value: unknown): void => { + if (value === undefined) return + const message = validate(value) + if (message !== undefined) fail(message) + } + const current: unknown = ctx.get(serviceName) + inspect(current) + ctx.on('internal/service', (name, value: unknown) => { + if (name === serviceName) inspect(value) + }, { global: true }) +} + +/** Structural runtime surface required from a Cordis service implementation. */ +export interface ServiceShapeInvariant { + /** Members that must be callable. */ + readonly methods: readonly string[] + /** Members that must be non-empty strings. */ + readonly stringProperties?: readonly string[] +} + +/** + * Describe the first missing member in a structural service implementation. + * This deliberately accepts test doubles and third-party implementations that + * satisfy the seam without inheriting the first-party abstract service class. + * @param value - candidate service implementation. + * @param shape - callable and string members owned by the service package. + * @returns the violated shape, or `undefined` when the candidate conforms. + */ +export function serviceShapeViolation( + value: unknown, + shape: ServiceShapeInvariant, +): string | undefined { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return 'service implementation must be an object' + } + const record = value as Record + for (const method of shape.methods) { + if (typeof record[method] !== 'function') return `service implementation must expose method ${JSON.stringify(method)}` + } + for (const property of shape.stringProperties ?? []) { + if (typeof record[property] !== 'string' || record[property].length === 0) { + return `service implementation must expose non-empty string ${JSON.stringify(property)}` + } + } + return undefined +} + +/** + * Report a failed package-owned synchronous invariant. + * @param fail - reporter bound to the package that owns the assertion. + * @param condition - condition that must hold. + * @param message - violated contract when `condition` is false. + * @returns nothing when the condition holds. + */ +export function assertInvariant( + fail: InvariantFailure, + condition: unknown, + message: string, +): void { + if (!condition) fail(message) +} + /** Internal effect shape used to join child startup before a companion loads. */ interface PendingInvariantRegistration extends PromiseLike<() => void> { (): void | Promise diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index b6b4d8ae48..bd9efcb74c 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-invariants`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-invariants/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-invariants`. @module @deepseek-ai/dsh-invariants/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from './index.ts' +import InvariantService, { observePluginInvariant, type InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' @@ -17,8 +10,19 @@ export const name = 'invariants-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + plugin: InvariantService, + name: 'InvariantService', + effects: [ + 'ctx.provide("invariants")', + ], + services: [ + 'invariants', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index afeae22fb7..44f8d61139 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' -import InvariantService, { InvariantError, type Config } from '@deepseek-ai/dsh-invariants' +import InvariantService, { + InvariantError, + assertInvariant, + observePluginInvariant, + observeServiceInvariant, + serviceShapeViolation, + type Config, + type InvariantInstaller, + type PluginInvariantContract, +} from '@deepseek-ai/dsh-invariants' declare module 'cordis' { interface Context { invariantProbe: InvariantProbeService + watchedInvariantProbe: WatchedInvariantProbeService } interface Events { @@ -18,6 +28,12 @@ class InvariantProbeService extends Service { } } +class WatchedInvariantProbeService extends Service { + constructor(ctx: Context) { + super(ctx, 'watchedInvariantProbe') + } +} + interface RuntimeRegistration extends PromiseLike<() => void> { (): void | Promise } @@ -264,3 +280,207 @@ describe('InvariantService lifecycle', () => { expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) }) }) + +describe('package-owned invariant helpers', () => { + async function registerInstaller( + ctx: Context, + packageName: string, + installer: InvariantInstaller, + ): Promise<() => void> { + const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) + const dispose = await Promise.resolve(registration) + return dispose + } + + function effectPlugin(options: { + name?: string + inject?: string[] + effect?: string + service?: string + } = {}) { + return { + name: options.name ?? 'effect-probe', + inject: options.inject ?? [], + apply(ctx: Context) { + if (options.service !== undefined) ctx.provide(options.service, {}) + if (options.effect !== undefined) { + ctx.effect(() => { + ctx.effect(() => () => {}, `${options.effect}.child`) + return () => {} + }, options.effect) + } + }, + } + } + + async function expectPluginViolation( + contract: PluginInvariantContract, + plugin: ReturnType, + message: RegExp, + ): Promise { + const { ctx } = await setup() + await registerInstaller(ctx, `@deepseek-ai/${contract.name}`, (child, fail) => { + observePluginInvariant(child, fail, contract) + }) + await expect(Promise.resolve(ctx.plugin(plugin))).rejects.toThrow(message) + } + + it('checks existing and later plugin fibers, including nested effects and alternatives', async () => { + const { ctx } = await setup() + await ctx.plugin(InvariantProbeService) + const plugin = effectPlugin({ + inject: ['invariantProbe'], + effect: 'probe.effect', + service: 'pluginProbe', + }) + await ctx.plugin(plugin) + const validated = vi.fn(() => undefined) + await registerInstaller(ctx, '@deepseek-ai/dsh-existing-probe', (child, fail) => { + observePluginInvariant(child, fail, { + plugin, + name: 'effect-probe', + inject: ['invariantProbe'], + effects: [['missing.effect', 'probe.effect.child']], + services: ['pluginProbe'], + validate: validated, + }) + }) + expect(validated).toHaveBeenCalledOnce() + + const later = effectPlugin({ name: 'later-probe', effect: 'later.effect' }) + await registerInstaller(ctx, '@deepseek-ai/dsh-later-probe', (child, fail) => { + observePluginInvariant(child, fail, { + plugin: later, + name: 'later-probe', + effects: ['later.effect'], + }) + }) + await ctx.plugin(later) + }) + + it('matches package plugins by Cordis name without importing their callback', async () => { + const { ctx } = await setup() + const plugin = { + name: 'name-only-probe', + apply(pluginCtx: Context) { + pluginCtx.effect(() => () => {}, 'name-only.effect') + pluginCtx.inject([], () => {}) + }, + } + await registerInstaller(ctx, '@deepseek-ai/dsh-name-only-probe', (child, fail) => { + observePluginInvariant(child, fail, { + name: 'name-only-probe', + effects: ['name-only.effect'], + }) + }) + await ctx.plugin(plugin) + }) + + it('rejects a contract that does not identify a plugin', async () => { + const { ctx } = await setup() + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { + observePluginInvariant(child, fail, { + plugin: {} as never, + name: 'invalid-plugin', + }) + })) + await expect(Promise.resolve(registration)).rejects.toThrow(/does not identify a Cordis plugin/) + }) + + it('rejects wrong plugin names, missing injections, effects, services, and custom checks', async () => { + const wrongName = effectPlugin({ name: 'actual-name', effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: wrongName, + name: 'expected-name', + }, wrongName, /plugin name must be "expected-name"/) + + const missingInjection = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: missingInjection, + name: 'effect-probe', + inject: ['missingService'], + }, missingInjection, /must inject "missingService"/) + + const missingEffect = effectPlugin() + await expectPluginViolation({ + plugin: missingEffect, + name: 'effect-probe', + effects: [['first.effect', 'second.effect']], + }, missingEffect, /must own effect "first.effect" or "second.effect"/) + + const missingService = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: missingService, + name: 'effect-probe', + services: ['missingService'], + }, missingService, /must provide service "missingService"/) + + const invalidCustom = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: invalidCustom, + name: 'effect-probe', + validate: () => 'custom plugin contract failed', + }, invalidCustom, /custom plugin contract failed/) + }) + + it('checks existing and future service implementations while ignoring unrelated changes', async () => { + const existing = await setup() + await existing.ctx.plugin(WatchedInvariantProbeService) + await registerInstaller(existing.ctx, '@deepseek-ai/dsh-existing-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( + value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' + )) + }) + + const future = await setup() + await registerInstaller(future.ctx, '@deepseek-ai/dsh-future-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( + value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' + )) + }) + await future.ctx.plugin(InvariantProbeService) + await future.ctx.plugin(WatchedInvariantProbeService) + + const invalid = await setup() + await registerInstaller(invalid.ctx, '@deepseek-ai/dsh-invalid-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', () => 'wrong watched service') + }) + await expect(Promise.resolve(invalid.ctx.plugin(WatchedInvariantProbeService))) + .rejects.toThrow(/wrong watched service/) + }) + + it('reports synchronous package assertions through the bound failure reporter', async () => { + const { ctx } = await setup() + const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { + assertInvariant(fail, true, 'must stay true') + }) + valid() + + const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { + assertInvariant(fail, false, 'must stay true') + })) + await expect(Promise.resolve(invalid)).rejects.toThrow(/must stay true/) + }) + + it('accepts structural service implementations and test doubles', () => { + expect(serviceShapeViolation({ kind: 'probe', run() {} }, { + methods: ['run'], + stringProperties: ['kind'], + })).toBeUndefined() + expect(serviceShapeViolation(Object.assign(() => {}, { run() {} }), { + methods: ['run'], + })).toBeUndefined() + }) + + it.each([ + { value: null, message: 'service implementation must be an object' }, + { value: 42, message: 'service implementation must be an object' }, + { value: {}, message: 'service implementation must expose method "run"' }, + { value: { run() {}, kind: '' }, message: 'service implementation must expose non-empty string "kind"' }, + ])('rejects invalid structural service implementations: $message', ({ value, message }) => { + expect(serviceShapeViolation(value, { + methods: ['run'], + stringProperties: ['kind'], + })).toBe(message) + }) +}) diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 50295cbedf..9c82c33686 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-replay`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-replay/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-replay`. @module @deepseek-ai/dsh-llm-replay/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' @@ -17,8 +10,21 @@ export const name = 'llm-replay-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-replay', + inject: [ + 'llm', + ], + effects: [ + [ + 'llm.registerAdapter()', + 'ctx.on("llm/stream")', + ], + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 9265a5f8b4..826d33dd30 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-loader-smoke`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-loader-smoke/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-loader-smoke. @module @deepseek-ai/dsh-loader-smoke/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' @@ -17,8 +11,25 @@ export const name = 'loader-smoke-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert default source mode and plain-Node built-artifact launch resolution. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') + assertInvariant(fail, resolveExampleMode('') === 'src', + 'an empty example-mode selection must preserve source-mode development') + const launch = resolveExampleLaunch({ + srcBin: '/workspace/probe/src/bin.ts', + mode: 'lib', + }) + assertInvariant(fail, + launch.command === process.execPath + && launch.args.length === 1 + && launch.args[0] === '/workspace/probe/lib/bin.js' + && launch.env.TSX_TSCONFIG_PATH === undefined, + 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') + return () => {} + }, 'loader-smoke: validate source and built launch resolution') +} /** * Register this package's invariant companion. diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index 468fe664b9..ce3bf24175 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tasks`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tasks/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tasks`. @module @deepseek-ai/dsh-tasks/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' @@ -17,8 +10,19 @@ export const name = 'tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'TaskService', + effects: [ + 'ctx.provide("tasks")', + 'tasks teardown', + ], + services: [ + 'tasks', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index fded38c895..e4af0931d2 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-tasks`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-tasks/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-tasks`. @module @deepseek-ai/dsh-tool-tasks/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' @@ -17,8 +10,20 @@ export const name = 'tool-tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-tasks', + inject: [ + 'tools', + 'tasks', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index 9e7b5b7d4b..b0e564dbbd 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-timeout-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-timeout-policy`. @module @deepseek-ai/dsh-timeout-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' @@ -17,8 +10,18 @@ export const name = 'timeout-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'timeout-policy', + inject: [ + 'tools', + ], + effects: [ + 'ctx.on("tools/execute")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index a5980342f3..9ad57156f4 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-todo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-todo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-todo`. @module @deepseek-ai/dsh-tool-todo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' @@ -17,8 +10,18 @@ export const name = 'tool-todo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-todo', + inject: [ + 'tools', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 2c081fc48c..1b98d6630c 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp`. @module @deepseek-ai/dsh-acp/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' @@ -17,8 +10,25 @@ export const name = 'acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'acp', + inject: [ + 'agents', + 'sessionPersistence', + 'tools', + 'userInteraction', + 'llm', + 'systemPrompt', + ], + effects: [ + 'userInteraction.registerProvider()', + 'ctx.on("session/event")', + 'acp.connection', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +37,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/src/config-path.ts b/packages/ui/app-boot/src/config-path.ts new file mode 100644 index 0000000000..bdfb933feb --- /dev/null +++ b/packages/ui/app-boot/src/config-path.ts @@ -0,0 +1,23 @@ +/** Snapshot-aware application configuration path selection. @module @deepseek-ai/dsh-app-boot/config-path */ + +import { basename, dirname, resolve } from 'node:path' + +/** + * Resolve the config to boot. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. + * @param configPath - requested config path, absolute or relative to `cwd`. + * @param snapshotMode - bin `$DSH_SNAPSHOT`; only `replay` swaps the basename. + * @param cwd - base for a relative `configPath`. + * @returns the absolute path of the config to boot. + */ +export function resolveConfigPath( + configPath: string, + snapshotMode: string | undefined, + cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 91ab0d3a2f..e929449287 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,29 +6,12 @@ */ import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' -/** - * Resolve the config to boot. Replay swaps a `cordis.yml` basename for - * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. - * @param configPath - the requested config path (absolute, or relative to `cwd`). - * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the - * basename. - * @param cwd - the base a relative `configPath` resolves against. - * @returns the absolute path of the config to boot. - */ -export function resolveConfigPath( - configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), -): string { - const absolute = resolve(cwd, configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} +export { resolveConfigPath } from './config-path.ts' /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 498d967799..30e2cfea69 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -1,14 +1,9 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-app-boot`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-app-boot/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-app-boot. @module @deepseek-ai/dsh-app-boot/invariant */ /* jscpd:ignore-start */ +import { resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' @@ -17,8 +12,20 @@ export const name = 'app-boot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert ordinary and replay config-path selection. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { resolveConfigPath } = await import('./config-path.ts') + const cwd = '/tmp/dsh-app-boot-invariant' + const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) + const replay = resolveConfigPath('cordis.yml', 'replay', cwd) + assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), + 'ordinary app boot must retain the requested config basename') + assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), + 'snapshot replay must select cordis.snapshot.yml in the requested config directory') + return () => {} + }, 'app-boot: validate ordinary and replay config selection') +} /** * Register this package's invariant companion. diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index 552c312481..aad2ac9f41 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-jsonrpc/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-jsonrpc`. @module @deepseek-ai/dsh-jsonrpc/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' @@ -17,8 +10,18 @@ export const name = 'jsonrpc-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'jsonrpc', + inject: [ + 'agents', + ], + effects: [ + 'jsonrpc.serve', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 1a774e2e4c..24191712bf 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-permission`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-permission/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-permission`. @module @deepseek-ai/dsh-permission/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' @@ -17,8 +10,22 @@ export const name = 'permission-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'PermissionService', + inject: [ + 'bash', + 'approval', + ], + effects: [ + 'ctx.provide("permission")', + ], + services: [ + 'permission', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 50b630bfd1..cf08a0bafb 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -12,7 +12,12 @@ async function mounted(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { const ctx = new Context() - ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' }) + ctx.provide('bash', { + sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } }) await ctx.plugin(PermissionService, options.config ?? {}) return ctx diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 443440a215..56bf12cda3 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio`. @module @deepseek-ai/dsh-stdio/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' @@ -17,8 +10,20 @@ export const name = 'stdio-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ui-stdio', + inject: [ + 'agents', + 'userInteraction', + ], + effects: [ + 'ctx.on("session/event")', + 'userInteraction.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index eebb1ced42..2b18cad1f3 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-ask-user`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-ask-user/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-ask-user`. @module @deepseek-ai/dsh-tool-ask-user/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' @@ -17,8 +10,19 @@ export const name = 'tool-ask-user-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-ask-user', + inject: [ + 'tools', + 'userInteraction', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index cba5fd9d2e..d3014146d7 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tui`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tui/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tui`. @module @deepseek-ai/dsh-tui/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tui' @@ -17,8 +10,21 @@ export const name = 'tui-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ui-tui', + inject: [ + 'agents', + 'userInteraction', + 'tools', + ], + effects: [ + 'ctx.on("session/event")', + 'userInteraction.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d60b317965..11ec395d90 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -94,7 +94,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise { async function configureAdvancedTools(ctx: Context): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry, { mode: 'code' }) - ctx.provide('workflows', {} as never) + ctx.provide('workflows', { start() {} } as never) await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 }) await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) } diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 73dee4f9f1..9a905be06f 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-user-approval`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-user-approval/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-approval`. @module @deepseek-ai/dsh-user-approval/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' @@ -17,8 +10,19 @@ export const name = 'user-approval-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ApprovalService', + effects: [ + 'ctx.provide("approval")', + 'ctx.on("agent/pre-step")', + ], + services: [ + 'approval', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 262681fc06..85029b692b 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-user-interaction`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-user-interaction/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-interaction`. @module @deepseek-ai/dsh-user-interaction/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' @@ -17,8 +10,18 @@ export const name = 'user-interaction-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'UserInteractionService', + effects: [ + 'ctx.provide("userInteraction")', + ], + services: [ + 'userInteraction', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index e932e7e35e..5ac98489cb 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-brand`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-brand/invariant - */ +/** Package-owned runtime contract for @deepseek-ai/dsh-brand. @module @deepseek-ai/dsh-brand/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' @@ -17,8 +11,15 @@ export const name = 'brand-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that the nominal-type primitive remains erased at runtime. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const brandRuntime = await import('./index.ts') + assertInvariant(fail, Object.keys(brandRuntime).length === 0, + 'the branded-id primitive must remain type-only with no runtime exports') + return () => {} + }, 'brand: validate type-only runtime erasure') +} /** * Register this package's invariant companion. diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 5874a57c1c..651d877e00 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -1,14 +1,9 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-home`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-home/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-home. @module @deepseek-ai/dsh-home/invariant */ /* jscpd:ignore-start */ +import { resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-home' @@ -17,8 +12,19 @@ export const name = 'home-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the canonical environment key and configured-path precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') + const environmentKey: string = DSH_HOME_ENV + assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), + 'the canonical Harness home environment key must remain DSH_HOME') + const configured = 'relative-invariant-home' + assertInvariant(fail, resolveDshHome(configured) === resolve(configured), + 'an explicitly configured Harness home must normalize to an absolute path') + return () => {} + }, 'home: validate canonical DSH home resolution') +} /** * Register this package's invariant companion. diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index c2cedfbb0d..ce0ddb653c 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -1,14 +1,10 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-paths`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-paths/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-paths. @module @deepseek-ai/dsh-paths/invariant */ /* jscpd:ignore-start */ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' @@ -17,8 +13,19 @@ export const name = 'paths-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert tilde expansion and explicit-over-environment home precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') + assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), + 'supported tilde prefixes must expand against the operating-system home') + const configured = 'relative-invariant-home' + const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) + assertInvariant(fail, resolved === resolve(configured), + 'an explicit DSH home must override the environment and normalize to an absolute path') + return () => {} + }, 'paths: validate DSH home resolution') +} /** * Register this package's invariant companion. diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 7516f9e5ed..380e4e3d4b 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-retention`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-retention/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-retention. @module @deepseek-ai/dsh-retention/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' @@ -17,8 +11,26 @@ export const name = 'retention-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert exact head-retention accounting after the budget is exceeded. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { ItemRetainer } = await import('./index.ts') + const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) + retainer.push('first') + retainer.push('second') + retainer.push('third') + const result = retainer.finish() + assertInvariant(fail, + result.items.join(',') === 'first,second' + && result.seen === 3 + && result.kept === 2 + && result.truncated + && result.omitted.kind === 'exact' + && result.omitted.count === 1, + 'head retention must keep the prefix and report exact seen, kept, and omitted counts') + return () => {} + }, 'retention: validate exact head accounting') +} /** * Register this package's invariant companion. diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 15140bb880..8302380eaa 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-timeout/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-timeout. @module @deepseek-ai/dsh-timeout/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' @@ -17,8 +11,21 @@ export const name = 'timeout-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert default-before-cap arithmetic and capability-code classification. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') + assertInvariant(fail, + clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, + 'timeout resolution must apply the default before capping and preserve smaller requests') + const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) + assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, + 'timeout classification must recover a matching capability-owned reason') + assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, + 'timeout classification must reject a reason owned by another capability') + return () => {} + }, 'timeout: validate resolution and reason classification') +} /** * Register this package's invariant companion. diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 008fe2f5e1..1cceb0f166 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-web`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-web/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-web`. @module @deepseek-ai/dsh-tool-web/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' @@ -17,8 +10,20 @@ export const name = 'tool-web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-web', + inject: [ + 'tools', + 'web', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index ef61e2a611..5a29395715 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-fetch-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web-fetch-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-fetch-local`. @module @deepseek-ai/dsh-web-fetch-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' @@ -17,8 +10,18 @@ export const name = 'web-fetch-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-fetch-local', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index 8781949be9..bfed9260dc 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-deepseek`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-deepseek`. * @module @deepseek-ai/dsh-web-search-deepseek/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' @@ -17,8 +13,18 @@ export const name = 'web-search-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-deepseek', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index a2dd956625..8d5da8d433 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-exa`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web-search-exa/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-exa`. @module @deepseek-ai/dsh-web-search-exa/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' @@ -17,8 +10,18 @@ export const name = 'web-search-exa-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-exa', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index fe82c79dae..7669f6e53a 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-perplexity`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-perplexity`. * @module @deepseek-ai/dsh-web-search-perplexity/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' @@ -17,8 +13,18 @@ export const name = 'web-search-perplexity-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-perplexity', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index b9b1b0d45d..395679cf36 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web`. @module @deepseek-ai/dsh-web/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' @@ -17,8 +10,18 @@ export const name = 'web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WebService', + effects: [ + 'ctx.provide("web")', + ], + services: [ + 'web', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index cd1f0e475b..966e4b3613 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-workflow`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-workflow/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-workflow`. @module @deepseek-ai/dsh-tool-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' @@ -17,8 +10,20 @@ export const name = 'tool-workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-workflow', + inject: [ + 'tools', + 'workflows', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6bcc40862c..27c0865d79 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow-workerthread`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow-workerthread`. * @module @deepseek-ai/dsh-workflow-workerthread/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' @@ -17,8 +13,21 @@ export const name = 'workflow-workerthread-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WorkerWorkflowEngine', + inject: [ + 'subagents', + ], + effects: [ + 'ctx.provide("workflows")', + ], + services: [ + 'workflows', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index b552021ca4..a37d480e69 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-workflow/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow`. @module @deepseek-ai/dsh-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' @@ -17,8 +10,12 @@ export const name = 'workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'workflows', value => serviceShapeViolation(value, { + methods: ['start'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/scripts/gen-package-invariants.ts b/scripts/gen-package-invariants.ts deleted file mode 100644 index d30ce5a3eb..0000000000 --- a/scripts/gen-package-invariants.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Generate or verify package-owned invariant companion baselines. */ - -import { readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { - GENERATED_INVARIANT_MARKER, - collectPackageInvariantViolations, - formatPackageInvariantViolation, - packageInvariantOwners, - renderBaselineInvariant, -} from './package-invariants.ts' - -const root = resolve(import.meta.dirname, '..') -const check = process.argv.includes('--check') - -if (!check) { - let generated = 0 - for (const owner of packageInvariantOwners(root)) { - const path = resolve(root, owner.sourcePath) - let current: string | undefined - try { - current = readFileSync(path, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (current !== undefined && !current.includes(GENERATED_INVARIANT_MARKER)) continue - const expected = renderBaselineInvariant(owner) - if (current === expected) continue - writeFileSync(path, expected) - generated += 1 - } - console.log(`gen-package-invariants: wrote ${generated} generated baseline companion(s).`) -} - -const violations = collectPackageInvariantViolations(root) -if (violations.length > 0) { - console.error('verify-package-invariants: violations found:') - for (const violation of violations) { - console.error(` ${formatPackageInvariantViolation(root, violation)}`) - } - process.exit(1) -} - -console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} package companion(s) conform.`) diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 698969b3d7..6ef9fce852 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -4,8 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { collectPackageInvariantViolations, - packageInvariantOwners, - renderBaselineInvariant, } from './package-invariants.ts' const roots: string[] = [] @@ -14,6 +12,18 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function handwrittenInvariant(packageName: string): string { + return ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, fail: (message: string) => never) => { + if (typeof ${JSON.stringify(packageName)} !== 'string') fail('package name must remain a string') +} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) +` +} + function fixture(options: { packageName?: string source?: string @@ -47,8 +57,7 @@ function fixture(options: { writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }], }, null, 2)}\n`) - const owner = packageInvariantOwners(root)[0]! - writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? renderBaselineInvariant(owner)) + writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName)) writeFileSync( join(dir, 'tsdown.config.ts'), options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n", @@ -56,8 +65,43 @@ function fixture(options: { return root } +function addConformingPackage(root: string, slug: string, packageName: string, source: string): void { + const dir = join(root, `packages/core/${slug}`) + mkdirSync(join(dir, 'src'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ + name: packageName, + exports: { + './invariant': { + types: './lib/types/invariant.d.ts', + default: './lib/invariant.js', + }, + }, + files: ['lib/invariant.js'], + peerDependencies: { '@deepseek-ai/dsh-invariants': '^0.0.1' }, + devDependencies: { '@deepseek-ai/dsh-invariants': 'workspace:^' }, + }, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + references: [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + writeFileSync(join(dir, 'src/invariant.ts'), source) + writeFileSync(join(dir, 'tsdown.config.ts'), "export default { entry: ['lib/types/invariant.js'] }\n") +} + +function nameObservedInvariant(packageName: string, pluginName: string): string { + return ` +import { observePluginInvariant } from '@deepseek-ai/dsh-invariants' +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (ctx: never, fail: (message: string) => never) => { + observePluginInvariant(ctx, fail, { name: ${JSON.stringify(pluginName)} }) +} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) +` +} + describe('package invariant gate', () => { - it('accepts a generated owner companion with publication metadata', () => { + it('accepts a hand-owned checking companion with publication metadata', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) @@ -82,9 +126,10 @@ describe('package invariant gate', () => { export const name = 'probe-invariant' export const inject = ['invariants'] const selected = process.env.PACKAGE_NAME -export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => { - ctx.invariants.register('@deepseek-ai/dsh-foreign', () => {}) - return ctx.invariants.register(selected!, () => {}) +const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') } +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => { + ctx.invariants.register('@deepseek-ai/dsh-foreign', install) + return ctx.invariants.register(selected!, install) } ` const violations = collectPackageInvariantViolations(fixture({ source })) @@ -94,11 +139,52 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => ])) }) - it('rejects edits to a generated baseline', () => { - const root = fixture() - const path = join(root, 'packages/core/probe/src/invariant.ts') - writeFileSync(path, `${renderBaselineInvariant(packageInvariantOwners(root)[0]!)}// stale\n`) + it('rejects generated markers and empty or reporter-free installers', () => { + const generated = fixture({ + source: `/** @generated scripts/gen-package-invariants.ts */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`, + }) + expect(collectPackageInvariantViolations(generated).map(violation => violation.message)) + .toContain('invariant companions must be hand-owned and may not carry @generated markers') + + const empty = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) +`, + }) + expect(collectPackageInvariantViolations(empty).map(violation => violation.message)) + .toEqual(expect.arrayContaining([ + 'install function must contain a package-owned invariant check', + 'install function must accept the bound failure reporter as its second parameter', + ])) + + const unused = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 } +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) +`, + }) + expect(collectPackageInvariantViolations(unused).map(violation => violation.message)) + .toContain('install function must use its bound failure reporter') + }) + + it('rejects duplicate name-based plugin observers across packages', () => { + const root = fixture({ + source: nameObservedInvariant('@deepseek-ai/dsh-probe', 'shared-runtime-name'), + }) + addConformingPackage( + root, + 'probe-two', + '@deepseek-ai/dsh-probe-two', + nameObservedInvariant('@deepseek-ai/dsh-probe-two', 'shared-runtime-name'), + ) expect(collectPackageInvariantViolations(root).map(violation => violation.message)) - .toContain('generated baseline is stale; run pnpm run gen-package-invariants') + .toContain('name-based plugin invariant "shared-runtime-name" is already owned by "@deepseek-ai/dsh-probe-two"') }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 45fb64829f..6448888680 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -1,16 +1,13 @@ /** - * Package-invariant companion discovery, generation, and structural checks. + * Package-invariant companion discovery and structural checks. * The runtime registry stays product-independent; this gate makes ownership * exhaustive across packages without centralizing package checks. */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { basename, dirname, relative, resolve, sep } from 'node:path' +import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' -/** Marker identifying baseline companions owned by this generator. */ -export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' - interface PackageManifest { name?: string exports?: Record @@ -53,53 +50,26 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { }) } -/** Render the generated ownership-only companion for a package without custom checks. */ -export function renderBaselineInvariant(owner: PackageInvariantOwner): string { - const serviceImport = owner.packageName === '@deepseek-ai/dsh-invariants' - ? './index.ts' - : '@deepseek-ai/dsh-invariants' - const pluginName = `${basename(owner.dir)}-invariant` - return `/** - * Generated invariant ownership companion for \`${owner.packageName}\`. - * Replace this file with package-owned checks while preserving its registration. - * - * ${GENERATED_INVARIANT_MARKER} - * @module ${owner.packageName}/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '${serviceImport}' - -const PACKAGE_NAME = '${owner.packageName}' - -/** Cordis companion plugin name. */ -export const name = '${pluginName}' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's 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)) -/* jscpd:ignore-end */ -` -} - /** Return all violations of the package-invariant companion contract. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] + const observedPluginNames = new Map() for (const owner of packageInvariantOwners(root)) { const manifest = readManifest(resolve(root, owner.manifestPath)) checkManifest(owner, manifest, violations) checkBuild(owner, root, violations) - checkSource(owner, root, violations) + for (const pluginName of checkSource(owner, root, violations)) { + const existing = observedPluginNames.get(pluginName) + if (existing === undefined) { + observedPluginNames.set(pluginName, owner) + } else { + addViolation( + violations, + owner.sourcePath, + `name-based plugin invariant ${JSON.stringify(pluginName)} is already owned by ${JSON.stringify(existing.packageName)}`, + ) + } + } } return violations } @@ -181,19 +151,18 @@ function checkSource( owner: PackageInvariantOwner, root: string, violations: PackageInvariantViolation[], -): void { +): string[] { const absolutePath = resolve(root, owner.sourcePath) if (!existsSync(absolutePath)) { addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') - return + return [] } const sourceText = readFileSync(absolutePath, 'utf8') - if (sourceText.includes(GENERATED_INVARIANT_MARKER) - && sourceText !== renderBaselineInvariant(owner)) { + if (sourceText.includes('@generated')) { addViolation( violations, owner.sourcePath, - 'generated baseline is stale; run pnpm run gen-package-invariants', + 'invariant companions must be hand-owned and may not carry @generated markers', ) } @@ -237,6 +206,87 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + checkInstaller(owner, sourceFile, violations) + return nameOnlyObservedPlugins(sourceFile) +} + +function nameOnlyObservedPlugins(sourceFile: ts.SourceFile): string[] { + const names: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && node.expression.text === 'observePluginInvariant') { + const contract = node.arguments[2] + if (contract !== undefined && ts.isObjectLiteralExpression(contract)) { + let hasExactPlugin = false + let name: string | undefined + for (const property of contract.properties) { + if (!ts.isPropertyAssignment(property)) continue + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined + if (key === 'plugin') hasExactPlugin = true + if (key === 'name') name = stringValue(property.initializer, new Map()) + } + if (!hasExactPlugin && name !== undefined) names.push(name) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return names +} + +function checkInstaller( + owner: PackageInvariantOwner, + sourceFile: ts.SourceFile, + violations: PackageInvariantViolation[], +): void { + let initializer: ts.Expression | undefined + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) + && declaration.name.text === 'install' + && declaration.initializer !== undefined) initializer = declaration.initializer + } + } + const installer = initializer === undefined ? undefined : installerFunction(initializer) + if (installer === undefined) { + addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks') + return + } + if (ts.isBlock(installer.body) && installer.body.statements.length === 0) { + addViolation(violations, owner.sourcePath, 'install function must contain a package-owned invariant check') + } + const reporter = installer.parameters[1]?.name + if (reporter === undefined || !ts.isIdentifier(reporter)) { + addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter') + return + } + if (!usesIdentifier(installer.body, reporter.text)) { + addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter') + } +} + +function usesIdentifier(node: ts.Node, name: string): boolean { + return ts.isIdentifier(node) && node.text === name + || node.getChildren().some(child => usesIdentifier(child, name)) +} + +function installerFunction( + initializer: ts.Expression, +): ts.ArrowFunction | ts.FunctionExpression | undefined { + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer + if (ts.isCallExpression(initializer) + && ts.isPropertyAccessExpression(initializer.expression) + && ts.isIdentifier(initializer.expression.expression) + && initializer.expression.expression.text === 'Object' + && initializer.expression.name.text === 'assign') { + const target = initializer.arguments[0] + if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target + } + return undefined } function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { diff --git a/scripts/verify-package-invariants.ts b/scripts/verify-package-invariants.ts new file mode 100644 index 0000000000..32e34539fa --- /dev/null +++ b/scripts/verify-package-invariants.ts @@ -0,0 +1,21 @@ +/** Verify package-owned invariant source and publication contracts. */ + +import { resolve } from 'node:path' +import { + collectPackageInvariantViolations, + formatPackageInvariantViolation, + packageInvariantOwners, +} from './package-invariants.ts' + +const root = resolve(import.meta.dirname, '..') +const violations = collectPackageInvariantViolations(root) + +if (violations.length > 0) { + console.error('verify-package-invariants: violations found:') + for (const violation of violations) { + console.error(` ${formatPackageInvariantViolation(root, violation)}`) + } + process.exit(1) +} + +console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`) diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index e55dd664c7..61589489f7 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L95) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L261) ### ctx.invariants.register(packageName, installer) @@ -29,4 +29,4 @@ Register one package's invariant installer. The package name is reserved even wh **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L137) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L303) From caaa1364ec1b65fd39ebbdf2b184dcba3fb38f6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:51:28 +0800 Subject: [PATCH 17/48] test(invariants): bound global companion topology --- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 4 +- ...7-19-package-owned-invariant-service.zh.md | 4 +- docs/testing.md | 2 +- packages/support/invariants/README.md | 2 +- scripts/test-invariants.spec.ts | 16 ++++- scripts/test-invariants.ts | 58 ++++++++++++++++--- 7 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 7bb90a9308..ef4090b3d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: d73cb196820369e41a748ad7a1c3c50b889d636f -2026-07-19-package-owned-invariant-service.zh.md: 644fd1f0668a097f028ec805c5cb425e536e277a +2026-07-19-package-owned-invariant-service.md: d7b77439c8cfdfdf2fc4f01e779a6d3c7f345457 +2026-07-19-package-owned-invariant-service.zh.md: b553d137f9f06b57f24672bf6984d087ca99ceb2 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index d73cb19682..d7b77439c8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin. Package tests add their owner's companion, one exhaustive topology mounts every companion, and focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -101,5 +101,5 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. +- Ordinary Vitest roots install the current test package's companion; one exhaustive topology retains repository-wide registration coverage without multiplying every child fiber across every test root. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 644fd1f066..b553d137f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务。包测试会添加其所有者的伴随插件,一个完整拓扑会挂载所有伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -101,5 +101,5 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 +- 普通 Vitest 根上下文会安装当前测试包的伴随插件;一个完整拓扑保留全仓库注册覆盖,而不会在每个测试根上下文中重复创建所有子 fiber。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/testing.md b/docs/testing.md index 3bf37fe79e..1fcefb4bc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. +Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. ## The with-key policy: inference is cheap here diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 11e3b2b5c6..7a5d0748e4 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -54,7 +54,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology mounts all companions once, while focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 77150abb74..5c3b9120bc 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' -import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' +import { + MANUAL_INVARIANT_TESTS, + testInvariantCompanionPaths, + testInvariantCompanions, +} from './test-invariants.ts' declare module 'cordis' { interface Context { @@ -17,7 +21,7 @@ class TestInvariantProbe extends Service { } describe('global test invariant host', () => { - it('loads every companion and reserves every package name with enabled checks', async () => { + it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -39,6 +43,14 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) + it('mounts the owning package companion while leaving non-package roots service-only', () => { + expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) + .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([]) + expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) + .toEqual(Object.keys(testInvariantCompanions).sort()) + }) + it('executes each companion registration with its owning package name', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 584db3d5d7..b91fdda462 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,7 +1,8 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement and every package companion before their first - * plugin starts. Focused invariant tests own their service topology explicitly. + * service with global enablement plus the current test package's companion. + * One topology test mounts every companion; focused invariant tests own their + * service topology explicitly. */ import { expect } from 'vitest' @@ -40,6 +41,7 @@ export const MANUAL_INVARIANT_TESTS = [ interface InvariantHost { readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap + readonly ready: Promise } type PluginFiber = ReturnType @@ -55,13 +57,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const host = hosts.get(root) ?? startInvariantHost(root) const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) - if (existing !== undefined) return existing + if (existing !== undefined) { + return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + } const fiber = originalPlugin.call(this, plugin, config, getOuterStack) // A root-level await is the test's composition boundary. Nested plugin // fibers must not await their own companion parent through the global host. if (this.ctx !== root) return fiber - return joinInvariantStartup(fiber, host.fibers) + return joinInvariantStartup(fiber, host.ready) } function usesManualInvariantTree(): boolean { @@ -69,6 +73,30 @@ function usesManualInvariantTree(): boolean { return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) } +const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const + +/** + * Select the package companions that an ordinary test root must register. + * Package tests receive their owner's checks; the dedicated topology test + * receives every owner so coverage and exhaustive runtime registration remain + * independently enforced. + * @param testPath - absolute or repo-relative normalized Vitest file path. + * @returns sorted `import.meta.glob` keys for companions to mount. + */ +export function testInvariantCompanionPaths(testPath: string): string[] { + const normalized = testPath.replaceAll('\\', '/') + const allPaths = Object.keys(testInvariantCompanions).sort() + if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths + + const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) + if (owner === null) return [] + const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` + if (testInvariantCompanions[companionPath] === undefined) { + throw new Error(`test invariants: package test has no companion at ${companionPath}`) + } + return [companionPath] +} + function startInvariantHost(root: Context): InvariantHost { const fibers: PluginFiber[] = [] const byCallback = new Map() @@ -81,21 +109,35 @@ function startInvariantHost(root: Context): InvariantHost { } mount(InvariantService, { enabled: true }) - for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + const testPath = expect.getState().testPath ?? '' + const companionPaths = testInvariantCompanionPaths(testPath) + for (const path of companionPaths) { + const companion = testInvariantCompanions[path] + if (companion === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } mount(companion) } - const host = { fibers, byCallback } + const [serviceFiber, ...companionFibers] = fibers + if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') + // A companion is initially PENDING on the invariant service, and Cordis + // Fiber.await() only joins work already in flight. Wait for the service to + // activate its dependants before joining their startup and failures. + const ready = serviceFiber.await() + .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) + .then(() => undefined) + const host = { fibers, byCallback, ready } hosts.set(root, host) return host } -function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { +function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { const readiness = fiber.await().then(async (loaded) => { - await Promise.all(invariantFibers.map(invariant => invariant.await())) + await invariantReady return loaded }) const joined = Object.create(fiber) as PluginFiber From e80fc3e61b64bd06e22f38a4d35691fa50dc6489 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:53:09 +0800 Subject: [PATCH 18/48] fix(invariants): join package checks at startup --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 14 +- ...-package-invariant-runtime-contracts.zh.md | 14 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 6 +- ...7-19-package-owned-invariant-service.zh.md | 6 +- docs/testing.md | 2 +- .../code-runtime/tests/service.spec.ts | 10 +- .../examples/jsonrpc-demo/src/invariant.ts | 11 +- packages/hooks/hook-protocol/src/invariant.ts | 29 ++- .../sandbox/sandbox/tests/invariant.spec.ts | 43 ++++ packages/sdk/create-sdk/src/invariant.ts | 35 ++- packages/sdk/helper/src/invariant.ts | 25 +- packages/sdk/scripts/src/invariant.ts | 29 +-- packages/sdk/telemetry/src/invariant.ts | 23 +- .../subagent-inprocess/src/invariant.ts | 15 +- .../subagent-subprocess/src/invariant.ts | 29 ++- .../support/acp-snapshot/src/invariant.ts | 35 ++- .../agent-loop-testkit/src/invariant.ts | 17 +- packages/support/invariants/README.md | 6 +- packages/support/invariants/src/index.ts | 229 ++++++++++++++---- .../support/invariants/tests/service.spec.ts | 93 ++++++- .../support/loader-smoke/src/invariant.ts | 31 ++- packages/ui/app-boot/src/invariant.ts | 21 +- packages/util/brand/src/invariant.ts | 11 +- packages/util/home/src/invariant.ts | 19 +- packages/util/paths/src/invariant.ts | 19 +- packages/util/retention/src/invariant.ts | 33 ++- packages/util/timeout/src/invariant.ts | 23 +- scripts/test-invariants.spec.ts | 16 +- scripts/test-invariants.ts | 58 ++++- 31 files changed, 596 insertions(+), 314 deletions(-) create mode 100644 packages/sandbox/sandbox/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 06d1f9e07e..48407e2272 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: d3b327694b0d6779409582a7bb199e9cd48a1e4c -2026-07-19-package-invariant-runtime-contracts.zh.md: 07c15e80587a292fc94e3537980ee26cbae15115 +2026-07-19-package-invariant-runtime-contracts.md: 65986fc0b3aab695d8512d9e221052e1db83445f +2026-07-19-package-invariant-runtime-contracts.zh.md: 0ae0bd305692ee71359c5500e477dee22575a9ef diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index d3b327694b..65986fc0b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -10,7 +10,7 @@ The package-owned invariant seam made registration and publication exhaustive, b Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. -Vitest also mounts every companion globally. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. +Vitest mounts each package test's owning companion globally, and one exhaustive topology mounts every companion. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. ## Decision @@ -31,17 +31,17 @@ At implementation time this covers all 91 workspace packages: four stateful comp ### Product-independent observers -`observePluginInvariant` checks existing fibers immediately and future active fibers through global Cordis lifecycle events. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. +`observePluginInvariant` checks existing fibers immediately and future active fibers through a callback/name index behind one root-shared Cordis lifecycle listener pair. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. `observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. -`assertInvariant` handles synchronous package algebra. Pure-package companions register an asynchronous child effect and dynamically import their owner inside that effect. This preserves atomic service-owned rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. +`assertInvariant` handles package algebra. Pure-package companions return an asynchronous installer promise and dynamically import their owner during child startup. The service joins that promise for atomic rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. ### Gate and test execution `verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. -The Vitest setup host mounts `InvariantService` with `{ enabled: true }` and all 91 companions before an ordinary Cordis root's first plugin. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. Focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. +The Vitest setup host mounts `InvariantService` with `{ enabled: true }` before an ordinary Cordis root's first plugin and adds the current test package's companion. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. One exhaustive topology mounts all 91 companions once to prove runtime registration and coverage; focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. @@ -50,7 +50,7 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. - **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. - **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. -- **Import every owner entrypoint statically from its companion.** Rejected because the global test host would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. +- **Import every owner entrypoint statically from its companion.** Rejected because owning and exhaustive test hosts would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. - **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. - **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. @@ -58,8 +58,8 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - Every package contributes an executable check; adding a package without one fails the top-level gate. - The invariant service remains product-independent while providing reusable lifecycle and shape observers. -- Ordinary unit, snapshot, and e2e tests run with global invariant enablement and every companion registered. +- Ordinary unit, snapshot, and e2e roots run with global invariant enablement and the test package's companion; one exhaustive topology registers every companion. - Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. - Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. -- More companion work runs during tests and selected deployments, trading small startup cost for immediate package-attributed failures. +- Relevant companion work runs during package tests and selected deployments, trading bounded startup cost for immediate package-attributed failures. - The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 07c15e8058..0ae0bd3056 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -10,7 +10,7 @@ Status: implemented 不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 -Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 +Vitest 会为每个包测试全局挂载其所有者伴随插件,并由一个完整拓扑挂载全部伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 ## 决策 @@ -31,17 +31,17 @@ Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试 ### 与产品无关的观察器 -`observePluginInvariant` 会立即检查已有 fiber,并通过全局 Cordis 生命周期事件检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 +`observePluginInvariant` 会立即检查已有 fiber,并通过根上下文共享的一对 Cordis 生命周期监听器背后的 callback/名称索引检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 `observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 -`assertInvariant` 处理同步包代数。纯包伴随插件注册异步子 effect,并在该 effect 内动态导入所有者。这样既保留服务拥有的原子回滚,又允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 +`assertInvariant` 处理包代数。纯包伴随插件返回异步 installer promise,并在子 fiber 启动期间动态导入所有者。服务会等待该 promise 以保证原子回滚,同时允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 ### 门禁与测试执行 `verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 -Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 91 个伴随插件,然后才启动普通 Cordis 根上下文的第一个插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 +Vitest setup host 会在普通 Cordis 根上下文启动第一个插件前,以 `{ enabled: true }` 挂载 `InvariantService`,并添加当前测试包的伴随插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。一个完整拓扑会一次挂载全部 91 个伴随插件,以证明运行时注册与覆盖率;选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 @@ -50,7 +50,7 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 - **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 - **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 -- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为全局测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 +- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为所有者测试 host 与完整测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 - **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 - **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 @@ -58,8 +58,8 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 - 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 -- 普通单元、snapshot 与 e2e 测试均全局启用不变式并注册每个伴随插件。 +- 普通单元、snapshot 与 e2e 根上下文均全局启用不变式并注册当前测试包的伴随插件;一个完整拓扑注册全部伴随插件。 - 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 - 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 -- 测试和被选部署会执行更多伴随工作,以少量启动成本换取即时且带包归属的失败。 +- 包测试和被选部署会执行相关伴随工作,以有界启动成本换取即时且带包归属的失败。 - 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 8e83577949..9ab8642f4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 3ffba4ff849b6e4be731586dc7d1b37f4c1a76eb -2026-07-19-package-owned-invariant-service.zh.md: d8be195eccb1ab53439b6cdb90b725ae0889c8a6 +2026-07-19-package-owned-invariant-service.md: 552b088c1cafc2fa762487f57fa1d4ad64390f7e +2026-07-19-package-owned-invariant-service.zh.md: 84bdc6a6a3baefe95713f8ce5b8a4e2af49b64eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 3ffba4ff84..552b088c1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -49,7 +49,7 @@ Blocklist matches override allowlist matches. Each list entry is a case-sensitiv The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. -An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion. One exhaustive topology mounts all package companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -101,5 +101,5 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. +- Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index d8be195ecc..84bdc6a6a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -49,7 +49,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 -启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包的伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -101,5 +101,5 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 +- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/testing.md b/docs/testing.md index 3bf37fe79e..1fcefb4bc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. +Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. ## The with-key policy: inference is cheap here diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 01736ea0ab..68bc809de7 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { InvariantError } from '@deepseek-ai/dsh-invariants' /** * Minimal concrete runtime: records requests, "executes" by invoking every @@ -96,6 +97,13 @@ describe('CodeRuntime service seam', () => { child.provide('codeRuntime', value as unknown as CodeRuntime) }, } - await expect(ctx.plugin(invalidRuntime)).rejects.toThrow(message) + let caught: unknown + try { + await ctx.plugin(invalidRuntime) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect((caught as Error).message).toMatch(message) }) }) diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index f65eee8165..a9352d02f8 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'jsonrpc-demo-invariant' export const inject = ['invariants'] /** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') - return () => {} - }, 'jsonrpc-demo: validate bin-only entrypoint') +const install: InvariantInstaller = async (_ctx, fail) => { + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') } /** diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 6503791f1c..997dd75b30 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -12,22 +12,21 @@ export const name = 'hook-protocol-invariant' export const inject = ['invariants'] /** Assert blocking-exit decoding and restrictive merge precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseHookOutput } = await import('./codec.ts') - const { mergeHookOutputs } = await import('./merge.ts') - const blocked = parseHookOutput(2, '', ' denied ') - assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', - 'exit 2 must decode as a block whose reason is trimmed stderr') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseHookOutput }, { mergeHookOutputs }] = await Promise.all([ + import('./codec.ts'), + import('./merge.ts'), + ]) + const blocked = parseHookOutput(2, '', ' denied ') + assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', + 'exit 2 must decode as a block whose reason is trimmed stderr') - const merged = mergeHookOutputs([ - { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, - { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, - ]) - assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', - 'deny must override allow and retain only the winning decision reason') - return () => {} - }, 'hook-protocol: validate decode and merge algebra') + const merged = mergeHookOutputs([ + { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, + { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, + ]) + assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', + 'deny must override allow and retain only the winning decision reason') } /** diff --git a/packages/sandbox/sandbox/tests/invariant.spec.ts b/packages/sandbox/sandbox/tests/invariant.spec.ts new file mode 100644 index 0000000000..24854b56ae --- /dev/null +++ b/packages/sandbox/sandbox/tests/invariant.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { InvariantError } from '@deepseek-ai/dsh-invariants' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' + +class StubSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { + argv: [...argv], + enforcement: 'full', + denialSignatures: [], + runnerFailureSignatures: [], + } + } +} + +describe('sandbox package invariant', () => { + it('accepts a provider that exposes the confinement seam', async () => { + const ctx = new Context() + await ctx.plugin(StubSandboxProvider) + expect(ctx.sandbox).toBeInstanceOf(StubSandboxProvider) + }) + + it('rejects a service binding without confine()', async () => { + const ctx = new Context() + const invalidSandbox = { + name: 'invalid-sandbox', + apply(child: Context) { + child.provide('sandbox', {} as SandboxProvider) + }, + } + let caught: unknown + try { + await ctx.plugin(invalidSandbox) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect(caught).toHaveProperty('packageName', '@deepseek-ai/dsh-sandbox') + expect((caught as Error).message).toMatch(/must expose method "confine"/) + }) +}) diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 66a95e6a38..417d936128 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -12,24 +12,23 @@ export const name = 'create-sdk-invariant' export const inject = ['invariants'] /** Assert the bin-only entrypoint and its core argument mapping. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseCreateArgs } = await import('./args.ts') - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the create-sdk library entrypoint must remain empty because the package is bin-only') - const parsed = parseCreateArgs([ - 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', - ]) - assertInvariant(fail, - parsed.directory === 'workspace' - && parsed.provider === 'custom' - && parsed.baseURL === 'https://example.test' - && parsed.runInterface === 'embed' - && parsed.install === false, - 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') - return () => {} - }, 'create-sdk: validate bin and argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseCreateArgs }, packageEntry] = await Promise.all([ + import('./args.ts'), + import('./index.ts'), + ]) + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the create-sdk library entrypoint must remain empty because the package is bin-only') + const parsed = parseCreateArgs([ + 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', + ]) + assertInvariant(fail, + parsed.directory === 'workspace' + && parsed.provider === 'custom' + && parsed.baseURL === 'https://example.test' + && parsed.runInterface === 'embed' + && parsed.install === false, + 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') } /** diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index e91fb012d5..5faf1ea49c 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -12,20 +12,17 @@ export const name = 'helper-invariant' export const inject = ['invariants'] /** Assert FeatureId's zero-cost representation and boundary validation. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { featureId } = await import('./ids.ts') - assertInvariant(fail, featureId('local-plugin') === 'local-plugin', - 'a valid feature id must preserve its runtime string value') - let rejected = false - try { - featureId('Invalid Feature') - } catch (error) { - rejected = error instanceof Error - } - assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') - return () => {} - }, 'dsh-helper: validate feature identities') +const install: InvariantInstaller = async (_ctx, fail) => { + const { featureId } = await import('./ids.ts') + assertInvariant(fail, featureId('local-plugin') === 'local-plugin', + 'a valid feature id must preserve its runtime string value') + let rejected = false + try { + featureId('Invalid Feature') + } catch (error) { + rejected = error instanceof Error + } + assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') } /** diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index 316e7ebed1..522791fe3b 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -12,22 +12,19 @@ export const name = 'scripts-invariant' export const inject = ['invariants'] /** Assert the launcher's opaque post-separator forwarding boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { splitForwardedArgs } = await import('./forwarding.ts') - const plain = splitForwardedArgs(['dev', 'src/index.ts']) - const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) - assertInvariant(fail, - plain.launcher.length === 2 - && plain.forwarded.length === 0 - && separated.launcher.length === 2 - && separated.launcher[1] === 'src/index.ts' - && separated.forwarded.length === 2 - && separated.forwarded[0] === '--inspect' - && separated.forwarded[1] === '9229', - 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') - return () => {} - }, 'dsh-sdk: validate command argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const { splitForwardedArgs } = await import('./forwarding.ts') + const plain = splitForwardedArgs(['dev', 'src/index.ts']) + const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) + assertInvariant(fail, + plain.launcher.length === 2 + && plain.forwarded.length === 0 + && separated.launcher.length === 2 + && separated.launcher[1] === 'src/index.ts' + && separated.forwarded.length === 2 + && separated.forwarded[0] === '--inspect' + && separated.forwarded[1] === '9229', + 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') } /** diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 8333b1ff71..9ec704b672 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'telemetry-invariant' export const inject = ['invariants'] /** Assert the final telemetry redaction boundary removes secrets without corrupting ordinary package metadata. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const [{ DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }, { telemetryRedactionViolation }] = await Promise.all([ - import('./secret-redactor.ts'), - import('./redaction-contract.ts'), - ]) - const redactor = new SecretRedactor() - const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) - assertInvariant(fail, - violation === undefined, - violation ?? 'telemetry redaction contract failed without a diagnostic') - return () => {} - }, 'telemetry: validate secret-redaction boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ telemetryRedactionViolation }, { DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }] = await Promise.all([ + import('./redaction-contract.ts'), + import('./secret-redactor.ts'), + ]) + const redactor = new SecretRedactor() + const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) + assertInvariant(fail, + violation === undefined, + violation ?? 'telemetry redaction contract failed without a diagnostic') } /** diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index a007200d73..caaf598f55 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -12,15 +12,12 @@ export const name = 'subagent-inprocess-invariant' export const inject = ['invariants'] /** Assert that structured-output guidance names the tool it actually installs. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') - assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), - 'the structured-output tool must retain a stable lowercase protocol name') - assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), - 'the structured-output instruction must name the exact installed tool') - return () => {} - }, 'subagent-inprocess: validate structured-output protocol') +const install: InvariantInstaller = async (_ctx, fail) => { + const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') + assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), + 'the structured-output tool must retain a stable lowercase protocol name') + assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), + 'the structured-output instruction must name the exact installed tool') } /** diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index ed29ecc855..9eed9ae9e3 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -13,19 +13,24 @@ export const name = 'subagent-subprocess-invariant' export const inject = ['invariants'] /** Assert ambient credential scrubbing and explicit credential precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { buildChildEnv } = await import('./index.ts') - const scrubbed = buildChildEnv({}) - const ambientSensitiveNames = Object.keys(process.env).filter(key => SENSITIVE_ENV_PATTERN.test(key)) - assertInvariant(fail, ambientSensitiveNames.every(key => !Object.hasOwn(scrubbed, key)), - 'subprocess environments must omit every credential-shaped ambient variable') +const install: InvariantInstaller = async (_ctx, fail) => { + const { buildChildEnv } = await import('./index.ts') + const ambientProbe = `DSH_INVARIANT_AMBIENT_TOKEN_${process.pid}` + assertInvariant(fail, SENSITIVE_ENV_PATTERN.test(ambientProbe), + 'the invariant ambient probe must remain credential-shaped') + process.env[ambientProbe] = 'must-not-reach-child' + let scrubbed: NodeJS.ProcessEnv + try { + scrubbed = buildChildEnv({}) + } finally { + Reflect.deleteProperty(process.env, ambientProbe) + } + assertInvariant(fail, !Object.hasOwn(scrubbed, ambientProbe), + 'subprocess environments must omit every credential-shaped ambient variable') - const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) - assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', - 'explicit child credentials must be applied after ambient scrubbing') - return () => {} - }, 'subagent-subprocess: validate child environment isolation') + const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) + assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', + 'explicit child credentials must be applied after ambient scrubbing') } /** diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index fc568d7b37..ac29b83cff 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -12,25 +12,22 @@ export const name = 'acp-snapshot-invariant' export const inject = ['invariants'] /** Assert stable JSON-RPC correlation and volatile-value tokenization. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { normalizeStdout } = await import('./normalize.ts') - const sessionId = '12345678-1234-1234-1234-123456789abc' - const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } - const raw = [ - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), - ].join('\n') - const normalized = normalizeStdout(raw, volatile) - assertInvariant(fail, - normalized.includes('"id":1') - && normalized.includes('"cwd":"{{cwd}}"') - && normalized.includes('"sessionId":"{{sessionId}}"'), - 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') - assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, - 'ACP stdout normalization must be idempotent') - return () => {} - }, 'acp-snapshot: validate stable transcript normalization') +const install: InvariantInstaller = async (_ctx, fail) => { + const { normalizeStdout } = await import('./normalize.ts') + const sessionId = '12345678-1234-1234-1234-123456789abc' + const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } + const raw = [ + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), + ].join('\n') + const normalized = normalizeStdout(raw, volatile) + assertInvariant(fail, + normalized.includes('"id":1') + && normalized.includes('"cwd":"{{cwd}}"') + && normalized.includes('"sessionId":"{{sessionId}}"'), + 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') + assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, + 'ACP stdout normalization must be idempotent') } /** diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fd72038d3f..69792b77ea 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -12,16 +12,13 @@ export const name = 'agent-loop-testkit-invariant' export const inject = ['invariants'] /** Assert the awaitable helper shape and optional-options call boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { mountAgentLoopTestDependencies } = await import('./index.ts') - assertInvariant(fail, - mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', - 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') - assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, - 'the prerequisite mount helper must keep its options argument optional') - return () => {} - }, 'agent-loop-testkit: validate prerequisite mount boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const { mountAgentLoopTestDependencies } = await import('./index.ts') + assertInvariant(fail, + mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', + 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') + assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, + 'the prerequisite mount helper must keep its options argument optional') } /** diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index eb26f57e43..7b922bcda9 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -16,7 +16,7 @@ Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: [ Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. -`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically. +`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Synchronous or asynchronous installer completion is joined before registration succeeds; failure disposes the child and releases ownership atomically. The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. @@ -32,7 +32,7 @@ Packages select the narrowest runtime form that protects their public contract: |---|---| | Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | | Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | -| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape in a child effect. | +| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape during child startup. | Four companions additionally install stateful event and request checks: @@ -62,7 +62,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so all package checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Plugin and service helpers multiplex package contracts through indexed lifecycle listeners shared by the Cordis root, while contribution disposal removes only that owner's contract. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology test mounts all companions once, and focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 374012fdbd..265596b677 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -28,15 +28,15 @@ export interface Config { */ export type InvariantFailure = (message: string) => never -/** Install one package's listeners into the registration's child context. */ +/** Install one package's checks into the registration's child context. */ export interface InvariantInstaller { /** * Install the package contribution. * @param ctx - child context owned by this invariant registration. * @param fail - reporter bound to the registering package name. - * @returns nothing after synchronous listener installation completes. + * @returns nothing, or a promise settling after asynchronous checks finish. */ - (ctx: Context, fail: InvariantFailure): void + (ctx: Context, fail: InvariantFailure): void | Promise /** Services the child installer fiber may access. */ readonly inject?: Inject } @@ -70,11 +70,111 @@ function collectEffectLabels(fiber: Fiber): ReadonlySet { return labels } +/** One package check routed by a root-shared plugin lifecycle dispatcher. */ +interface PluginObservation { + readonly callback: globalThis.Function | undefined + readonly contract: PluginInvariantContract + readonly fail: InvariantFailure +} + +/** Indexed plugin checks and the two lifecycle listeners shared by one root. */ +interface PluginObservationHub { + readonly byCallback: Map> + readonly byName: Map> +} + +const pluginObservationHubs = new WeakMap() + +/** Check one already-matched active plugin fiber. */ +function inspectPluginObservation(observation: PluginObservation, fiber: Fiber): void { + if (fiber.state !== FiberState.ACTIVE || fiber.uid === null) return + const { callback, contract, fail } = observation + if (callback !== undefined && fiber.name !== contract.name) { + fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) + } + const injections = new Set(Object.keys(fiber.inject)) + for (const service of contract.inject ?? []) { + if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) + } + + const effectLabels = collectEffectLabels(fiber) + for (const requirement of contract.effects ?? []) { + const alternatives = typeof requirement === 'string' ? [requirement] : requirement + if (!alternatives.some(label => effectLabels.has(label))) { + fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) + } + } + for (const service of contract.services ?? []) { + const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { + const implementation = fiber.ctx.reflect.store[key as symbol] + return implementation?.fiber === fiber && implementation.name === service + }) + if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) + } + const message = contract.validate?.(fiber, effectLabels) + if (message !== undefined) fail(message) +} + +/** Route one lifecycle notification only to checks that can match its runtime. */ +function inspectObservedPlugin(hub: PluginObservationHub, fiber: Fiber): void { + const callback = fiber.runtime?.callback + if (callback !== undefined) { + for (const observation of hub.byCallback.get(callback) ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const runtimeName = fiber.runtime?.name + if (runtimeName !== undefined) { + for (const observation of hub.byName.get(runtimeName) ?? []) { + inspectPluginObservation(observation, fiber) + } + } +} + +/** Return the root's shared plugin dispatcher, creating its two listeners once. */ +function pluginObservationHub(ctx: Context): PluginObservationHub { + const root = ctx.root + const existing = pluginObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: PluginObservationHub = { + byCallback: new Map(), + byName: new Map(), + } + pluginObservationHubs.set(root, hub) + root.on('internal/plugin', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + root.on('internal/status', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + return hub +} + +/** Add one plugin observation to a typed exact-key index. */ +function addIndexedPluginObservation( + index: Map>, + key: Key, + observation: PluginObservation, +): () => void { + const observations = index.get(key) ?? new Set() + index.set(key, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) index.delete(key) + } +} + +/** Add one observation to its exact callback or runtime-name index. */ +function addPluginObservation(hub: PluginObservationHub, observation: PluginObservation): () => void { + if (observation.callback === undefined) { + return addIndexedPluginObservation(hub.byName, observation.contract.name, observation) + } + return addIndexedPluginObservation(hub.byCallback, observation.callback, observation) +} + /** * Observe one package plugin and fail whenever an active fiber violates its * declared name, dependency, effect, service, or package-specific contract. * Existing fibers are checked immediately; later starts and HMR activations - * are checked through Cordis lifecycle events. + * are checked through two indexed lifecycle listeners shared by the root. * @param ctx - invariant child context that owns the observers. * @param fail - reporter bound to the package that owns the plugin. * @param contract - expected runtime facts for the package plugin. @@ -90,50 +190,79 @@ export function observePluginInvariant( fail('invariant contract does not identify a Cordis plugin') } - const inspect = (fiber: Fiber): void => { - const matches = callback === undefined - ? fiber.runtime?.name === contract.name - : fiber.runtime?.callback === callback - if (!matches || fiber.state !== FiberState.ACTIVE) return - if (callback !== undefined && fiber.name !== contract.name) { - fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) - } - const injections = new Set(Object.keys(fiber.inject)) - for (const service of contract.inject ?? []) { - if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) - } - - const effectLabels = collectEffectLabels(fiber) - for (const requirement of contract.effects ?? []) { - const alternatives = typeof requirement === 'string' ? [requirement] : requirement - if (!alternatives.some(label => effectLabels.has(label))) { - fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) - } - } - for (const service of contract.services ?? []) { - const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { - const implementation = fiber.ctx.reflect.store[key as symbol] - return implementation?.fiber === fiber && implementation.name === service - }) - if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) - } - const message = contract.validate?.(fiber, effectLabels) - if (message !== undefined) fail(message) - } + const observation: PluginObservation = { callback, contract, fail } if (contract.plugin === undefined) { for (const runtime of ctx.registry.values()) { - for (const fiber of runtime.fibers) inspect(fiber) + if (runtime.name !== contract.name) continue + for (const fiber of runtime.fibers) inspectPluginObservation(observation, fiber) } } else { - for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) inspect(fiber) + for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const hub = pluginObservationHub(ctx) + ctx.effect( + () => addPluginObservation(hub, observation), + `invariants.observePlugin(${JSON.stringify(contract.name)})`, + ) +} + +/** One structural check routed by a root-shared service lifecycle dispatcher. */ +interface ServiceObservation { + readonly fail: InvariantFailure + readonly validate: (value: unknown) => string | undefined +} + +/** Service checks and the single service listener shared by one root. */ +interface ServiceObservationHub { + readonly byName: Map> +} + +const serviceObservationHubs = new WeakMap() + +/** Check one present service implementation. */ +function inspectServiceObservation(observation: ServiceObservation, value: unknown): void { + if (value === undefined) return + const message = observation.validate(value) + if (message !== undefined) observation.fail(message) +} + +/** Return the root's shared service dispatcher, creating its listener once. */ +function serviceObservationHub(ctx: Context): ServiceObservationHub { + const root = ctx.root + const existing = serviceObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: ServiceObservationHub = { byName: new Map() } + serviceObservationHubs.set(root, hub) + root.on('internal/service', (name, value: unknown) => { + for (const observation of hub.byName.get(name) ?? []) { + inspectServiceObservation(observation, value) + } + }, { global: true }) + return hub +} + +/** Add one service observation to its exact service-name index. */ +function addServiceObservation( + hub: ServiceObservationHub, + serviceName: string, + observation: ServiceObservation, +): () => void { + const observations = hub.byName.get(serviceName) ?? new Set() + hub.byName.set(serviceName, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) hub.byName.delete(serviceName) } - ctx.on('internal/plugin', inspect, { global: true }) - ctx.on('internal/status', inspect, { global: true }) } /** - * Validate every current and future implementation bound to one Cordis service. + * Validate every current and future implementation bound to one Cordis + * service through the root's indexed shared service listener. * @param ctx - invariant child context that owns the service observer. * @param fail - reporter bound to the package that owns the service seam. * @param serviceName - Cordis service name to observe. @@ -146,16 +275,14 @@ export function observeServiceInvariant( serviceName: string, validate: (value: unknown) => string | undefined, ): void { - const inspect = (value: unknown): void => { - if (value === undefined) return - const message = validate(value) - if (message !== undefined) fail(message) - } + const observation: ServiceObservation = { fail, validate } const current: unknown = ctx.get(serviceName) - inspect(current) - ctx.on('internal/service', (name, value: unknown) => { - if (name === serviceName) inspect(value) - }, { global: true }) + inspectServiceObservation(observation, current) + const hub = serviceObservationHub(ctx) + ctx.effect( + () => addServiceObservation(hub, serviceName, observation), + `invariants.observeService(${JSON.stringify(serviceName)})`, + ) } /** Structural runtime surface required from a Cordis service implementation. */ @@ -297,7 +424,7 @@ export class InvariantService extends Service { * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void { @@ -324,11 +451,11 @@ export class InvariantService extends Service { } } - const installInvariant = (childCtx: Context) => { + const installInvariant = (childCtx: Context) => ( installer(childCtx, (message): never => { throw new InvariantError(packageName, message) }) - } + ) const child = ctx.plugin(installer.inject === undefined ? installInvariant : Object.assign(installInvariant, { inject: installer.inject })) diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 44f8d61139..76fda543ad 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -273,6 +273,25 @@ describe('InvariantService lifecycle', () => { expect(retry).toHaveBeenCalledOnce() }) + it('joins asynchronous checks and rolls back their effects on failure', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async (child, fail) => { + child.on('invariants-test/ping', leaked, { global: true }) + await Promise.resolve() + fail('asynchronous check failed') + })) + await expect(Promise.resolve(failed)).rejects.toThrow(/asynchronous check failed/) + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + + const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async () => { + await Promise.resolve() + })) + await retry + await retry() + }) + it('releases a synchronous reservation if the service fiber is already inactive', async () => { const { ctx, fiber } = await setup() const service = ctx.invariants @@ -282,11 +301,15 @@ describe('InvariantService lifecycle', () => { }) describe('package-owned invariant helpers', () => { + interface InvariantDisposer { + (): void | Promise + } + async function registerInstaller( ctx: Context, packageName: string, installer: InvariantInstaller, - ): Promise<() => void> { + ): Promise { const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) const dispose = await Promise.resolve(registration) return dispose @@ -376,6 +399,38 @@ describe('package-owned invariant helpers', () => { await ctx.plugin(plugin) }) + it('multiplexes same-runtime plugin checks through one root listener pair and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-first', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: firstValidation }) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-second', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: secondValidation }) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/plugin")')).toHaveLength(1) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/status")')).toHaveLength(1) + + const plugin = effectPlugin({ name: 'shared-plugin-probe' }) + const firstFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + await firstFiber.dispose() + const secondFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + + await second() + await secondFiber.dispose() + await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + }) + it('rejects a contract that does not identify a plugin', async () => { const { ctx } = await setup() const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { @@ -449,12 +504,46 @@ describe('package-owned invariant helpers', () => { .rejects.toThrow(/wrong watched service/) }) + it('multiplexes same-name service checks through one root listener and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-first', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', firstValidation) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-second', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', secondValidation) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/service")')).toHaveLength(1) + + const firstFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + const firstCallsAfterDisposal = firstValidation.mock.calls.length + const secondCallsBeforeRemount = secondValidation.mock.calls.length + await firstFiber.dispose() + const secondFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterDisposal) + expect(secondValidation.mock.calls.length).toBeGreaterThan(secondCallsBeforeRemount) + + await second() + const firstCallsAfterBothDisposals = firstValidation.mock.calls.length + const secondCallsAfterBothDisposals = secondValidation.mock.calls.length + await secondFiber.dispose() + await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterBothDisposals) + expect(secondValidation).toHaveBeenCalledTimes(secondCallsAfterBothDisposals) + }) + it('reports synchronous package assertions through the bound failure reporter', async () => { const { ctx } = await setup() const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { assertInvariant(fail, true, 'must stay true') }) - valid() + await valid() const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { assertInvariant(fail, false, 'must stay true') diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 826d33dd30..6fbb41a123 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -12,23 +12,20 @@ export const name = 'loader-smoke-invariant' export const inject = ['invariants'] /** Assert default source mode and plain-Node built-artifact launch resolution. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') - assertInvariant(fail, resolveExampleMode('') === 'src', - 'an empty example-mode selection must preserve source-mode development') - const launch = resolveExampleLaunch({ - srcBin: '/workspace/probe/src/bin.ts', - mode: 'lib', - }) - assertInvariant(fail, - launch.command === process.execPath - && launch.args.length === 1 - && launch.args[0] === '/workspace/probe/lib/bin.js' - && launch.env.TSX_TSCONFIG_PATH === undefined, - 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') - return () => {} - }, 'loader-smoke: validate source and built launch resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') + assertInvariant(fail, resolveExampleMode('') === 'src', + 'an empty example-mode selection must preserve source-mode development') + const launch = resolveExampleLaunch({ + srcBin: '/workspace/probe/src/bin.ts', + mode: 'lib', + }) + assertInvariant(fail, + launch.command === process.execPath + && launch.args.length === 1 + && launch.args[0] === '/workspace/probe/lib/bin.js' + && launch.env.TSX_TSCONFIG_PATH === undefined, + 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') } /** diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 30e2cfea69..8591c60dd1 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -13,18 +13,15 @@ export const name = 'app-boot-invariant' export const inject = ['invariants'] /** Assert ordinary and replay config-path selection. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveConfigPath } = await import('./config-path.ts') - const cwd = '/tmp/dsh-app-boot-invariant' - const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) - const replay = resolveConfigPath('cordis.yml', 'replay', cwd) - assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), - 'ordinary app boot must retain the requested config basename') - assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), - 'snapshot replay must select cordis.snapshot.yml in the requested config directory') - return () => {} - }, 'app-boot: validate ordinary and replay config selection') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveConfigPath } = await import('./config-path.ts') + const cwd = '/tmp/dsh-app-boot-invariant' + const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) + const replay = resolveConfigPath('cordis.yml', 'replay', cwd) + assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), + 'ordinary app boot must retain the requested config basename') + assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), + 'snapshot replay must select cordis.snapshot.yml in the requested config directory') } /** diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index 5ac98489cb..d632921d35 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'brand-invariant' export const inject = ['invariants'] /** Assert that the nominal-type primitive remains erased at runtime. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const brandRuntime = await import('./index.ts') - assertInvariant(fail, Object.keys(brandRuntime).length === 0, - 'the branded-id primitive must remain type-only with no runtime exports') - return () => {} - }, 'brand: validate type-only runtime erasure') +const install: InvariantInstaller = async (_ctx, fail) => { + const brandRuntime = await import('./index.ts') + assertInvariant(fail, Object.keys(brandRuntime).length === 0, + 'the branded-id primitive must remain type-only with no runtime exports') } /** diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 651d877e00..fc7c9c3129 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -13,17 +13,14 @@ export const name = 'home-invariant' export const inject = ['invariants'] /** Assert the canonical environment key and configured-path precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') - const environmentKey: string = DSH_HOME_ENV - assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), - 'the canonical Harness home environment key must remain DSH_HOME') - const configured = 'relative-invariant-home' - assertInvariant(fail, resolveDshHome(configured) === resolve(configured), - 'an explicitly configured Harness home must normalize to an absolute path') - return () => {} - }, 'home: validate canonical DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') + const environmentKey: string = DSH_HOME_ENV + assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), + 'the canonical Harness home environment key must remain DSH_HOME') + const configured = 'relative-invariant-home' + assertInvariant(fail, resolveDshHome(configured) === resolve(configured), + 'an explicitly configured Harness home must normalize to an absolute path') } /** diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index ce0ddb653c..20a3962ff4 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -14,17 +14,14 @@ export const name = 'paths-invariant' export const inject = ['invariants'] /** Assert tilde expansion and explicit-over-environment home precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') - assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), - 'supported tilde prefixes must expand against the operating-system home') - const configured = 'relative-invariant-home' - const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) - assertInvariant(fail, resolved === resolve(configured), - 'an explicit DSH home must override the environment and normalize to an absolute path') - return () => {} - }, 'paths: validate DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') + assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), + 'supported tilde prefixes must expand against the operating-system home') + const configured = 'relative-invariant-home' + const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) + assertInvariant(fail, resolved === resolve(configured), + 'an explicit DSH home must override the environment and normalize to an absolute path') } /** diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 380e4e3d4b..fa8ab7d36a 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -12,24 +12,21 @@ export const name = 'retention-invariant' export const inject = ['invariants'] /** Assert exact head-retention accounting after the budget is exceeded. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { ItemRetainer } = await import('./index.ts') - const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) - retainer.push('first') - retainer.push('second') - retainer.push('third') - const result = retainer.finish() - assertInvariant(fail, - result.items.join(',') === 'first,second' - && result.seen === 3 - && result.kept === 2 - && result.truncated - && result.omitted.kind === 'exact' - && result.omitted.count === 1, - 'head retention must keep the prefix and report exact seen, kept, and omitted counts') - return () => {} - }, 'retention: validate exact head accounting') +const install: InvariantInstaller = async (_ctx, fail) => { + const { ItemRetainer } = await import('./index.ts') + const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) + retainer.push('first') + retainer.push('second') + retainer.push('third') + const result = retainer.finish() + assertInvariant(fail, + result.items.join(',') === 'first,second' + && result.seen === 3 + && result.kept === 2 + && result.truncated + && result.omitted.kind === 'exact' + && result.omitted.count === 1, + 'head retention must keep the prefix and report exact seen, kept, and omitted counts') } /** diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 8302380eaa..2d9d22f5d6 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'timeout-invariant' export const inject = ['invariants'] /** Assert default-before-cap arithmetic and capability-code classification. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') - assertInvariant(fail, - clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, - 'timeout resolution must apply the default before capping and preserve smaller requests') - const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) - assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, - 'timeout classification must recover a matching capability-owned reason') - assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, - 'timeout classification must reject a reason owned by another capability') - return () => {} - }, 'timeout: validate resolution and reason classification') +const install: InvariantInstaller = async (_ctx, fail) => { + const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') + assertInvariant(fail, + clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, + 'timeout resolution must apply the default before capping and preserve smaller requests') + const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) + assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, + 'timeout classification must recover a matching capability-owned reason') + assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, + 'timeout classification must reject a reason owned by another capability') } /** diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 77150abb74..5c3b9120bc 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' -import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' +import { + MANUAL_INVARIANT_TESTS, + testInvariantCompanionPaths, + testInvariantCompanions, +} from './test-invariants.ts' declare module 'cordis' { interface Context { @@ -17,7 +21,7 @@ class TestInvariantProbe extends Service { } describe('global test invariant host', () => { - it('loads every companion and reserves every package name with enabled checks', async () => { + it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -39,6 +43,14 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) + it('mounts the owning package companion while leaving non-package roots service-only', () => { + expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) + .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([]) + expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) + .toEqual(Object.keys(testInvariantCompanions).sort()) + }) + it('executes each companion registration with its owning package name', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 584db3d5d7..b91fdda462 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,7 +1,8 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement and every package companion before their first - * plugin starts. Focused invariant tests own their service topology explicitly. + * service with global enablement plus the current test package's companion. + * One topology test mounts every companion; focused invariant tests own their + * service topology explicitly. */ import { expect } from 'vitest' @@ -40,6 +41,7 @@ export const MANUAL_INVARIANT_TESTS = [ interface InvariantHost { readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap + readonly ready: Promise } type PluginFiber = ReturnType @@ -55,13 +57,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const host = hosts.get(root) ?? startInvariantHost(root) const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) - if (existing !== undefined) return existing + if (existing !== undefined) { + return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + } const fiber = originalPlugin.call(this, plugin, config, getOuterStack) // A root-level await is the test's composition boundary. Nested plugin // fibers must not await their own companion parent through the global host. if (this.ctx !== root) return fiber - return joinInvariantStartup(fiber, host.fibers) + return joinInvariantStartup(fiber, host.ready) } function usesManualInvariantTree(): boolean { @@ -69,6 +73,30 @@ function usesManualInvariantTree(): boolean { return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) } +const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const + +/** + * Select the package companions that an ordinary test root must register. + * Package tests receive their owner's checks; the dedicated topology test + * receives every owner so coverage and exhaustive runtime registration remain + * independently enforced. + * @param testPath - absolute or repo-relative normalized Vitest file path. + * @returns sorted `import.meta.glob` keys for companions to mount. + */ +export function testInvariantCompanionPaths(testPath: string): string[] { + const normalized = testPath.replaceAll('\\', '/') + const allPaths = Object.keys(testInvariantCompanions).sort() + if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths + + const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) + if (owner === null) return [] + const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` + if (testInvariantCompanions[companionPath] === undefined) { + throw new Error(`test invariants: package test has no companion at ${companionPath}`) + } + return [companionPath] +} + function startInvariantHost(root: Context): InvariantHost { const fibers: PluginFiber[] = [] const byCallback = new Map() @@ -81,21 +109,35 @@ function startInvariantHost(root: Context): InvariantHost { } mount(InvariantService, { enabled: true }) - for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + const testPath = expect.getState().testPath ?? '' + const companionPaths = testInvariantCompanionPaths(testPath) + for (const path of companionPaths) { + const companion = testInvariantCompanions[path] + if (companion === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } mount(companion) } - const host = { fibers, byCallback } + const [serviceFiber, ...companionFibers] = fibers + if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') + // A companion is initially PENDING on the invariant service, and Cordis + // Fiber.await() only joins work already in flight. Wait for the service to + // activate its dependants before joining their startup and failures. + const ready = serviceFiber.await() + .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) + .then(() => undefined) + const host = { fibers, byCallback, ready } hosts.set(root, host) return host } -function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { +function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { const readiness = fiber.await().then(async (loaded) => { - await Promise.all(invariantFibers.map(invariant => invariant.await())) + await invariantReady return loaded }) const joined = Object.create(fiber) as PluginFiber From 512ef2a18e73f930077f91800d0b1e6beaeedd3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:00:40 +0800 Subject: [PATCH 19/48] docs(invariants): refresh installer API catalogs --- docs/cordis-catalog/services.md | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ++-- website/zh-CN/api/harness/invariants.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7da704df75..79e540e7bd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -489,13 +489,13 @@ Package-owned invariant registry with global and regex-based selection. * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:261`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:388`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 40b8f38471..bceeed2ad9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -256,7 +256,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register(packageName: string, installer: InvariantInstaller): () => void', - jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - synchronous listener installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', + jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - listener or startup-check installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', }, ], }, @@ -1192,7 +1192,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvariantInstaller', - declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void;\n readonly inject?: Inject;\n}', + declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}', }, { name: 'JsonValue', diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index 61589489f7..e2f582a566 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L261) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L388) ### ctx.invariants.register(packageName, installer) @@ -16,7 +16,7 @@ Package-owned invariant registry with global and regex-based selection. * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void @@ -25,8 +25,8 @@ register(packageName: string, installer: InvariantInstaller): () => void Register one package's invariant installer. The package name is reserved even when filtering disables its checks. Enabled installers run in a child fiber; failure disposes that fiber and releases the reservation. - `packageName` — full npm package name that owns the contribution. -- `installer` — synchronous listener installer for the child context. +- `installer` — listener or startup-check installer for the child context. **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L303) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L430) From 311503dfc68f7ac68e6d45cce31b3e2c9c54d22a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 13:59:02 +0800 Subject: [PATCH 20/48] Declare LSP packages for example configs --- examples/package.json | 3 +++ pnpm-lock.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/examples/package.json b/examples/package.json index 3a5f90d30e..a7f31153e1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-lsp": "workspace:*", + "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -38,6 +40,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca5a81da13..7824512d72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,12 @@ importers: '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay + '@deepseek-ai/dsh-lsp': + specifier: workspace:* + version: link:../packages/lsp/lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:* + version: link:../packages/lsp/lsp-local '@deepseek-ai/dsh-permission': specifier: workspace:* version: link:../packages/ui/permission @@ -191,6 +197,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:* version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-lsp': + specifier: workspace:* + version: link:../packages/lsp/tool-lsp '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent From be84a73f5662756bbb692355e9b6888f37a625b6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 14:40:18 +0800 Subject: [PATCH 21/48] Stabilize process-group coverage across POSIX --- packages/lsp/lsp-local/src/connection.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index e655877eb5..6acb321e90 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -206,6 +206,8 @@ export class LspConnection { return true } catch (error) { const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes + whether lifecycle tests observe this branch platform-dependent. */ if (code === 'ESRCH') return false /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ From cfa180c127715d52d5deb53b56c14725dd7dd73d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 15:34:00 +0800 Subject: [PATCH 22/48] Resolve compaction policy per routed model --- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 4 +- ...ction-pressure-and-overflow-recovery.zh.md | 4 +- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 12 +- ...026-07-15-replay-token-meter-service.zh.md | 12 +- ...el-context-and-compaction-policy.i18n.yaml | 6 + ...ted-model-context-and-compaction-policy.md | 57 ++++ ...-model-context-and-compaction-policy.zh.md | 57 ++++ .../2026-06-18-compaction-capability-seam.md | 4 +- docs/config-catalog.md | 43 ++- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 10 + docs/core-data-structures/llm-streaming.md | 13 +- docs/event-producer-consumer.md | 2 +- examples/acp-agent/cordis.yml | 14 +- examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 9 +- examples/jsonrpc-agent/cordis.yml | 7 +- examples/repl-agent/cordis.yml | 4 +- examples/repl-agent/tests/compaction.e2e.ts | 4 +- examples/repl-agent/tests/harness.ts | 14 +- packages/compact/compact-basic/README.md | 29 +- packages/compact/compact-basic/src/config.ts | 309 ++++++++++++++---- packages/compact/compact-basic/src/index.ts | 129 ++++++-- .../compact/compact-basic/src/summarizer.ts | 9 +- packages/compact/compact-basic/src/types.ts | 57 +++- .../compact-basic/tests/compact-basic.spec.ts | 274 ++++++++++++++-- .../tests/compact-loop-repro.spec.ts | 12 +- .../tests/loader-composition.spec.ts | 25 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/llm/README.md | 2 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 18 +- packages/llm/llm-deepseek/src/index.ts | 12 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 18 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 7 + packages/llm/llm/README.md | 5 +- packages/llm/llm/src/index.ts | 46 ++- packages/llm/llm/src/types.ts | 6 + packages/llm/llm/tests/service.spec.ts | 44 ++- packages/llm/token-meter/README.md | 14 +- packages/llm/token-meter/src/index.ts | 35 +- packages/llm/token-meter/src/types.ts | 7 +- .../llm/token-meter/tests/token-meter.spec.ts | 34 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 1 + website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/llm.md | 33 +- website/zh-CN/api/harness/token-meter.md | 17 +- website/zh-CN/guide/config.md | 32 +- 54 files changed, 1210 insertions(+), 319 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index a6344d276f..ee9ecbf681 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: f1a1868cd00007fb24efb21779dcc94c098b54e2 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: a4993de2830301610bb2a9b0d28e8bbdf0ed9c46 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: e9e1215734aaf4765ff572476132d7715e3a55ec +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5c5082345d0fabf7854d670de0fc28dfb3de5e7b diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index f1a1868cd0..e9e1215734 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -32,9 +32,9 @@ If cancellation lands after assistant tool calls are durable but before all call `CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. -For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. +For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index a4993de283..5c5082345d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -32,9 +32,9 @@ Status: implemented `CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 -对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 +对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index d49aedb545..c560903ca7 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 9bbc177f456e006179c466f8c245e4599db3dd5a -2026-07-15-replay-token-meter-service.zh.md: 4437626c8651a80537d45197a93733271a592173 +2026-07-15-replay-token-meter-service.md: 4bedbb0cb9fa383108a688dbbb32546c7f39bd20 +2026-07-15-replay-token-meter-service.zh.md: ccf1b014cd86d16ef498b4209818e78be562e66f diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 9bbc177f45..4bedbb0cb9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md) ## Problem -Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. +Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how many tokens does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can lack chunk provenance, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result. @@ -14,9 +14,9 @@ Provider usage is not a complete answer. It describes one successful call under ### One concrete LLM-family service -`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly. +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `measure(session, requestHeader?)` and `estimateMessage(message)`; consumers call the singleton service directly. -The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies. +The service has no configuration. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, capacity settings, density settings, tokenizer backends, or language-specific strategies. Exact provider/model capacity is a separate adapter-owned query, as specified by the [routed model context and compaction policy Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md). ### Per-session replay folds @@ -34,7 +34,7 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. -Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. +Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. @@ -46,14 +46,14 @@ Unit tests cover fixed estimation, envelope invalidation and anchor replacement, - **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. - **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. -- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select. +- **Put model-keyed windows and density profiles in the meter** — rejected because replay estimation does not own model routing or capacity facts. The route-owning adapter exposes capacity, while compact-basic owns the consumer-specific threshold and retention policy. - **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence. - **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request. ## Consequences - Token pressure has one replay-aware owner that compaction and future plugins can share. -- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed. +- The default makes the meter a zero-config composition entry; deployments configure capacity on each route-owning adapter and optional policy overrides on compact-basic. - Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. - Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4437626c86..ccf1b014cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 +上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。 @@ -14,9 +14,9 @@ Status: implemented ### 一个具体的 LLM 家族服务 -`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。精确提供方/模型容量由独立的适配器查询拥有,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 ### 逐会话回放折叠 @@ -34,7 +34,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 -压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 +压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 @@ -46,14 +46,14 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket - **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 - **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 -- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。 +- **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compact-basic 则拥有消费方专用的阈值与保留策略。 - **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。 - **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 ## 后果 - Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。 -- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。 +- 默认值让 meter 成为零配置组合项;部署在各个路由所属适配器上配置容量,并在 compact-basic 上配置可选策略覆盖。 - 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 - 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml new file mode 100644 index 0000000000..c9290db5ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml @@ -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 +2026-07-20-routed-model-context-and-compaction-policy.md: f0b9288d3d864bfcc2964862b1ff294406daa345 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: cda740a5671a3ef8a5bb415e5cc45ca8397c1c59 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md new file mode 100644 index 0000000000..f0b9288d3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -0,0 +1,57 @@ +# Agent Note: Routed model context and compaction policy + +Status: implemented + +English | [中文](2026-07-20-routed-model-context-and-compaction-policy.zh.md) + +## Problem + +Compaction cannot safely apply one global context window when a process routes requests to models with different capacities. The same model id can also exist under multiple providers, and an adapter may accept dynamic ids absent from its advisory catalog. A wrong capacity either compacts too late and triggers avoidable overflow or compacts too early and discards useful context. + +Neither obvious configuration owner is sufficient. Compact-basic is optional and does not know which models an adapter accepts. LLM adapters own model routing but must not depend on an optional compaction plugin or absorb consumer-specific threshold, retention, summarizer, and retry policy. The design needs an authoritative capacity fact and optional per-target compaction policy without creating a second model registry. + +## Decision + +### Adapters own exact-route capacity + +`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity. + +The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model. Its two default model entries publish 128,000 tokens; an explicit entry without capacity and an unlisted pass-through id return `undefined`. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. + +### Token measurement remains model-agnostic + +`dsh-token-meter` has no configuration and no model profiles. It owns one fixed replay fold and returns absolute estimated token pressure plus positional surface prices. Removing global capacity keeps measurement reusable when compact-basic is absent and prevents replay accounting from becoming another model registry. + +### Compact-basic resolves a target spec + +Compact-basic owns consumer policy. Top-level fields define defaults; `modelPolicies` contains partial overrides keyed by the exact `{ provider, model }` pair. Duplicate targets and unknown or invalid fields fail plugin load. `thresholdRatio` defaults to `0.8`, and retention defaults to `retainRatio: 0.16`; callers may use an absolute `retainTokens` instead, but the two retention forms are mutually exclusive. After inheritance, a ratio retention that is not below its threshold ratio also fails plugin load because no model capacity can make that policy valid. + +For proactive pressure, compact-basic reads the latest durable request route, resolves its adapter capacity and exact-target policy, and scales ratios into a `ResolvedCompactSpec`. It performs this resolution on every check, so a provider or model switch in one session changes capacity and policy immediately. An absolute retained budget that is not below the scaled threshold fails when the target capacity first makes that comparison possible. + +The same exact-target override can select summarization provider/model, summarization output cap, convergence retries, and overflow retry cap. These are compaction concerns and never enter the adapter seam. + +### Target-specific pressure failures preserve optional composition + +An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same per-route suppression applies when resolved capacity exposes an invalid absolute retention budget, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress. + +## Testing + +Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek configured/default/unlisted behavior and pi-ai exact descriptor resolution. Compact tests cover ratio scaling, exact provider/model overrides, load-time rejection of invalid merged ratios, runtime absolute-budget validation, same-model-id provider switches, target-specific warning suppression, and capacity-independent overflow recovery. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters. + +## Alternatives considered + +- **Put capacity and all policies in compact-basic** — rejected because compact-basic would duplicate adapter model knowledge, dynamic unlisted models would require parallel registration, and capacity would disappear when compaction is not installed. +- **Put compaction policy in each LLM adapter** — rejected because adapters must remain independent of optional consumers, while summarization and retry policy are not provider facts. +- **Make `listModels()` authoritative** — rejected because discovery is advisory and some adapters intentionally accept dynamic ids. Correctness metadata must not turn selector membership into a routing whitelist. +- **Add per-model folds to token-meter** — rejected because the replay algorithm is shared; only the capacity and consumer policy change. Multiple folds would duplicate state without improving estimation. +- **Create a standalone model-context registry** — rejected because the adapter already owns authoritative route resolution. A second registry would introduce lifecycle ordering, duplicate-key, and drift problems without an independent backend. + +## Consequences + +- Capacity has one authoritative owner at the provider seam, while compaction policy stays in the optional consuming plugin. +- The same compact-basic instance safely handles different windows, provider switches, and identical model ids under different providers without consulting discovery metadata. +- LLM-only and meter-only compositions remain valid; loading compact-basic adds no reverse dependency from adapters. +- Deployments using explicit DeepSeek model lists must provide `contextWindow` for proactive pressure on those entries. Missing metadata is visible instead of silently applying a wrong global fallback. +- Ratio defaults scale naturally across models, while exact-target absolute retention remains available for deployment-specific behavior. + +This note supersedes the global-capacity and no-model-policy parts of the [replay token meter service Agent Note](2026-07-15-replay-token-meter-service.md). Its single-fold measurement decision remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md new file mode 100644 index 0000000000..cda740a567 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 路由模型上下文与压缩策略 + +Status: implemented + +[English](2026-07-20-routed-model-context-and-compaction-policy.md) | 中文 + +## 问题 + +当一个进程把请求路由到不同容量的模型时,压缩不能安全地应用同一个全局上下文窗口。相同模型 id 也可能存在于多个提供方下,适配器还可能接受不在建议目录中的动态 id。错误容量要么让压缩触发过晚并造成原本可避免的溢出,要么让压缩触发过早并丢弃有用上下文。 + +两个直观的配置归属方都无法独立解决问题。Compact-basic 是可选插件,不知道适配器接受哪些模型。LLM 适配器拥有模型路由,但不能依赖可选压缩插件,也不应吸收消费方专用的阈值、保留、摘要器与重试策略。该设计既需要权威容量事实和可选的逐目标压缩策略,又不能建立第二套模型注册表。 + +## 决策 + +### 适配器拥有精确路由容量 + +`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。 + +手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`。两个默认模型项都公开 128,000 token;未提供容量的显式模型项与未列出的透传 id 返回 `undefined`。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 + +### Token 计量保持模型无关 + +`dsh-token-meter` 没有配置,也没有模型 profile。它拥有一个固定回放折叠,并返回绝对估算 token 压力与逐位置表层价格。移除全局容量后,未加载 compact-basic 时仍可复用计量,同时避免让回放核算变成另一套模型注册表。 + +### Compact-basic 解析目标规格 + +Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolicies` 包含以精确 `{ provider, model }` 组合为键的部分覆盖。重复目标、未知字段或无效字段都会让插件加载失败。`thresholdRatio` 默认为 `0.8`,保留策略默认为 `retainRatio: 0.16`;调用方也可以改用绝对 `retainTokens`,但两种保留形式互斥。完成继承后,如果保留比例不小于阈值比例,插件也会加载失败,因为任何模型容量都无法让该策略有效。 + +对于主动压力检查,compact-basic 读取最新持久请求路由,解析其适配器容量与精确目标策略,再把比例缩放为 `ResolvedCompactSpec`。每次检查都会重新解析,因此同一会话切换提供方或模型后,容量与策略会立即变化。若绝对保留预算不小于缩放后的阈值,系统会在目标容量首次允许比较两者时失败。 + +同一精确目标覆盖还可以选择摘要提供方/模型、摘要输出上限、收敛重试次数与溢出重试上限。这些都属于压缩问题,不会进入适配器 seam。 + +### 目标专用压力错误仍保留可选组合 + +缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大的平衡缩减,并在替换无法证明进展时保留原始提供方错误。 + +## 测试 + +服务测试覆盖分离上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的配置值、默认值与未列出行为,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture 会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。 + +## 考虑过的替代方案 + +- **把容量与所有策略都放进 compact-basic**——不予采纳,因为 compact-basic 会复制适配器的模型知识,未列出的动态模型需要并行注册,而且未安装压缩时容量也会消失。 +- **把压缩策略放进各个 LLM 适配器**——不予采纳,因为适配器必须独立于可选消费方,而摘要与重试策略也不是提供方事实。 +- **让 `listModels()` 成为权威来源**——不予采纳,因为发现能力只是建议信息,一些适配器有意接受动态 id。正确性元数据不能把选择器成员关系变成路由白名单。 +- **给 token-meter 增加逐模型折叠**——不予采纳,因为回放算法可以共享,变化的只有容量与消费方策略。多个折叠会重复状态,却不会改善估算。 +- **建立独立模型上下文注册表**——不予采纳,因为适配器已经拥有权威路由解析。第二套注册表会引入生命周期顺序、重复键与漂移问题,却没有独立后端。 + +## 后果 + +- 容量在提供方 seam 上拥有唯一权威归属方,而压缩策略留在可选消费插件中。 +- 同一个 compact-basic 实例无需查询发现元数据,就能安全处理不同窗口、提供方切换,以及不同提供方下的相同模型 id。 +- 仅 LLM 与仅 meter 的组合仍然有效;加载 compact-basic 不会让适配器产生反向依赖。 +- 使用显式 DeepSeek 模型列表的部署必须为需要主动压力检查的条目提供 `contextWindow`。系统会暴露缺失元数据,而不是静默应用错误的全局回退值。 +- 比例默认值会随模型自然缩放,同时仍可按精确目标使用绝对保留值,以满足部署专用行为。 + +本记录取代[回放式 token 计量服务 Agent Note](2026-07-15-replay-token-meter-service.md) 中的全局容量与无模型策略部分,单折叠计量决策保持不变。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index a7b06a8379..f90541f29b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -52,7 +52,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. -`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches the resolved retained-token budget and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. +`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional exact provider/model policies partially override the top-level defaults; pressure scales ratios against capacity from the route-owning LLM adapter, while `retainTokens` can replace ratio retention. Retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow needs no capacity metadata and bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. The ownership split is specified by the [routed model context and compaction policy Agent Note](../architecture/2026-07-20-routed-model-context-and-compaction-policy.md). ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 11550beefb..bf55077c64 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -287,15 +287,25 @@ Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../package Requires: `llm` · `tokenMeter` ```ts config-catalog -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + auto?: boolean +} + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -303,12 +313,18 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ - auto?: boolean +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string } ``` -Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts) +Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/compact-basic/src/types.ts) ## `@deepseek-ai/dsh-fs-local` @@ -437,6 +453,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } ``` @@ -1005,11 +1023,8 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..61e137ccb2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -473,7 +473,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:50`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..8ff9c8db0b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -508,6 +508,16 @@ listProviders(): LlmProviderInfo[] */ async listModels(provider: string): Promise +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -523,9 +533,9 @@ async listModels(provider: string): Promise stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:118`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1106,7 +1116,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b87513e11..67be566f48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -190,6 +190,16 @@ interface LlmModelInfo { } ``` +Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 41ce6427e6..f370edb84a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -132,7 +132,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -156,6 +156,17 @@ declare abstract class LlmAdapter { * @returns discoverable models in adapter-preferred order. */ listModels(_provider: string): Promise; + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5e56af710d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:50`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 510fac46b6..b54e47dac9 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,6 +9,11 @@ config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-flash + contextWindow: 256000 + - id: deepseek-v4-pro + contextWindow: 256000 # The default composition confines bash to the workspace and asks before a # wider retry. Snapshots use danger-full-access; DSH_PERMISSION_MODE overrides @@ -48,20 +53,17 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - # FIXME: Resolve compaction config per model; this capacity assumes a 256k context window. - contextWindow: 256000 # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.08 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9533af28d3..49fcac96aa 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -21,6 +21,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_headless_token_meter plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_headless_compact_basic plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -52,6 +54,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..a4e02d2de0 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -11,7 +11,9 @@ baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro + contextWindow: 128000 - id: deepseek-v4-flash + contextWindow: 128000 - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -34,13 +36,14 @@ factual. # Summarize an older range when derived history approaches the context window. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 00ef48f4ed..5c8029db6b 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -63,12 +63,13 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index a8a211a795..a782fb9903 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -47,12 +47,12 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/examples/repl-agent/tests/compaction.e2e.ts b/examples/repl-agent/tests/compaction.e2e.ts index d992fc9efa..fcebddb863 100644 --- a/examples/repl-agent/tests/compaction.e2e.ts +++ b/examples/repl-agent/tests/compaction.e2e.ts @@ -33,9 +33,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Reasoning tokens require a larger generation cap than the retained checkpoint. ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, - tokenMeter: { - contextWindow: 2000, - }, + modelContextWindow: 2000, compact: { thresholdRatio: 0.5, retainTokens: 400, diff --git a/examples/repl-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts index eeba57fc61..d7777acbb6 100644 --- a/examples/repl-agent/tests/harness.ts +++ b/examples/repl-agent/tests/harness.ts @@ -8,7 +8,6 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -45,8 +44,8 @@ export interface CodingHarnessOptions { * compaction plugin (the default suites run without it). */ compact?: BasicCompactConfig - /** Optional token-meter capacity loaded before compact-basic. */ - tokenMeter?: TokenMeterConfig + /** Test-only context capacity advertised for `deepseek-v4-flash`. */ + modelContextWindow?: number } export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { @@ -55,14 +54,15 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio systemPrompt: { persona: options.persona ?? '' }, }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { + models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], + }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) - // Compaction is opt-in: only the compaction e2e loads the reusable meter and - // backend, with a lower context window so a short real session crosses the threshold. + // Compaction is opt-in: only the compaction e2e loads the reusable meter and backend. if (options.compact !== undefined) { - await ctx.plugin(TokenMeterService, options.tokenMeter) + await ctx.plugin(TokenMeterService) await ctx.plugin(BasicCompactService, options.compact) } // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 6a67d2dfd4..01729bfd36 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,31 +9,38 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. +- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. -- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Overflow recovery** — provider-confirmed overflow does not require capacity metadata: it bypasses normal pressure and retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) -Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected. +Every setting is optional. Top-level policy fields are defaults for every routed model; `modelPolicies` applies partial overrides to exact provider/model pairs. At pressure time, compact-basic asks the owning LLM adapter for that route's context capacity and resolves absolute budgets. Unrecognized keys, duplicate targets, mutually exclusive retention forms, and a merged `retainRatio` that is not below `thresholdRatio` fail plugin load. An absolute `retainTokens` budget that is not below its scaled threshold fails on the first resolvable target because that comparison requires model capacity. | Key | Required | Meaning | |---|---|---| -| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | -| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | +| `thresholdRatio` | no (default `0.8`) | Compact at `floor(routedContextWindow × ratio)`. | +| `retainRatio` | no (default `0.16`) | Recent surface budget kept verbatim as a fraction of the routed context window; mutually exclusive with `retainTokens`. | +| `retainTokens` | no | Absolute recent surface budget kept verbatim; mutually exclusive with `retainRatio` and must be below the resolved threshold. | | `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | | `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | +| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. | | `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | +Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry. + +An adapter may return no capacity for a valid dynamic route, and resolved capacity may expose an invalid absolute retention budget. Manual pressure checks then throw a target-specific configuration error; the automatic listener warns once for that exact target and continues with full history. Unrelated operational failures remain independently visible. Canonical provider overflow still attempts recovery because the provider has already established that compaction is necessary. + ## Usage ```ts @@ -52,6 +59,20 @@ export function apply(ctx: Context): void { Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +For example, the same compact plugin can safely serve models with different capacities and one target-specific policy: + +```yaml +- name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.16 + modelPolicies: + - provider: local + model: small-context + thresholdRatio: 0.7 + retainTokens: 2048 +``` + ## Model Experience ### Conversation history diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 1587fac272..90adb5b5f7 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -1,111 +1,298 @@ /** - * Runtime defaulting and policy validation for compact-basic. + * Load-time validation and routed-model policy resolution for compact-basic. * * @module @deepseek-ai/dsh-compact-basic/config */ import { deepFreeze } from '@deepseek-ai/dsh-llm' -import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' -import type { BasicCompactConfig, ResolvedConfig } from './types.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { + BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, + ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, +} from './types.ts' -/** Default request-pressure fraction of the token meter's context window. */ +/** Default request-pressure fraction for every routed model. */ const DEFAULT_THRESHOLD_RATIO = 0.8 -/** Default verbatim-tail fraction of the token meter's context window. */ +/** Default verbatim-tail fraction for every routed model. */ const DEFAULT_RETAIN_RATIO = 0.16 -/** Complete public configuration key set. */ -const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ +/** Fields shared by top-level defaults and exact-target overrides. */ +const POLICY_CONFIG_KEYS = [ 'thresholdRatio', + 'retainRatio', 'retainTokens', 'summarizationProvider', 'summarizationModel', 'maxTokens', 'compactionRetries', 'maxOverflowRetries', +] as const + +/** Complete public top-level configuration key set. */ +const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ + ...POLICY_CONFIG_KEYS, + 'modelPolicies', 'auto', ]) -/** Reject stale or misspelled keys before defaults can hide them. */ -function validateConfigKeys(config: BasicCompactConfig): void { - for (const key of Object.keys(config)) { - if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) { - throw new Error( - `BasicCompactConfig: unknown key "${key}" ` - + '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, ' - + 'maxTokens, compactionRetries, maxOverflowRetries, auto)', - ) - } +/** Complete exact-target override key set. */ +const MODEL_POLICY_KEYS: ReadonlySet = new Set([ + 'provider', + 'model', + ...POLICY_CONFIG_KEYS, +]) + +/** Target-specific pressure configuration failure eligible for warning suppression. */ +export class TargetPressureConfigError extends Error { + /** + * @param targetKey - exact provider/model route used as the warning key. + * @param message - actionable configuration failure detail. + */ + constructor(readonly targetKey: string, message: string) { + super(message) } } /** - * Resolve defaults and validate the service-wide compaction policy. - * @param config - raw compact-basic configuration. - * @param tokenMeter - token meter supplying the context capacity. - * @returns a detached deeply immutable configuration. + * Resolve and validate service defaults plus exact-target partial overrides. + * @param config - untrusted plugin configuration after Loader normalization. + * @returns detached immutable defaults and validated exact-target overrides. */ -export function resolveConfig( - config: BasicCompactConfig = {}, - tokenMeter: TokenMeterService, -): ResolvedConfig { - validateConfigKeys(config) +export function resolveConfig(config: BasicCompactConfig = {}): ResolvedConfig { + validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactConfig') + validatePolicy(config, 'BasicCompactConfig') + if (config.auto !== undefined && typeof config.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean') + } + const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO - const retainTokens = config.retainTokens - ?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO) - const resolved: ResolvedConfig = { + const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO }) + validateRatioRetention(thresholdRatio, retention, 'BasicCompactConfig') + const modelPolicies = resolveModelPolicies(config.modelPolicies) + for (const [index, policy] of modelPolicies.entries()) { + validateRatioRetention( + policy.thresholdRatio ?? thresholdRatio, + resolveRetention(policy, retention), + `BasicCompactConfig: modelPolicies[${index}]`, + ) + } + + return deepFreeze({ thresholdRatio, - retainTokens, + ...retention, summarizationProvider: config.summarizationProvider ?? '', summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, maxOverflowRetries: config.maxOverflowRetries ?? 1, + modelPolicies, auto: config.auto ?? true, - } + }) +} - assertRatio('thresholdRatio', resolved.thresholdRatio) - assertNonNegativeInteger('retainTokens', resolved.retainTokens) - const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio) - if (resolved.retainTokens >= thresholdTokens) { - throw new Error( - `BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`, +/** + * Merge the exact provider/model override over the validated default policy. + * @param config - validated service defaults and override table. + * @param target - exact durable provider/model route to match. + * @returns detached immutable policy before model-capacity scaling. + */ +export function resolveTargetPolicy( + config: ResolvedConfig, + target: Pick, +): ResolvedTargetPolicy { + const override = config.modelPolicies.find(policy => ( + policy.provider === target.provider && policy.model === target.model + )) + const inheritedRetention: ResolvedRetention = config.retainTokens === undefined + ? { retainRatio: config.retainRatio } + : { retainTokens: config.retainTokens } + return deepFreeze({ + target: { provider: target.provider, model: target.model }, + thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio, + ...resolveRetention(override ?? {}, inheritedRetention), + summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider, + summarizationModel: override?.summarizationModel ?? config.summarizationModel, + maxTokens: override?.maxTokens ?? config.maxTokens, + compactionRetries: override?.compactionRetries ?? config.compactionRetries, + maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries, + }) +} + +/** + * Scale one routed policy into concrete token budgets for its model capacity. + * @param policy - merged policy for the exact routed target. + * @param contextWindow - positive adapter-owned capacity for that target. + * @returns detached immutable pressure and retention budgets. + */ +export function resolveCompactSpec( + policy: ResolvedTargetPolicy, + contextWindow: number, +): ResolvedCompactSpec { + const targetKey = `${policy.target.provider}/${policy.target.model}` + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: contextWindow (${contextWindow}) must be a positive integer`, ) } - assertPositiveInteger('maxTokens', resolved.maxTokens) - assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) - assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries) - if (typeof resolved.summarizationProvider !== 'string') { - throw new Error('BasicCompactConfig: summarizationProvider must be a string') - } - if (typeof resolved.summarizationModel !== 'string') { - throw new Error('BasicCompactConfig: summarizationModel must be a string') - } - if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) { - throw new Error( - 'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty', + const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio) + const retainTokens = policy.retainTokens === undefined + ? Math.floor(contextWindow * policy.retainRatio) + : policy.retainTokens + if (retainTokens >= thresholdTokens) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: ${policy.target.provider}/${policy.target.model} retainTokens ` + + `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`, ) } - if (typeof resolved.auto !== 'boolean') { - throw new Error('BasicCompactConfig: auto must be a boolean') - } - return deepFreeze(resolved) + return deepFreeze({ + target: { ...policy.target }, + contextWindow, + thresholdRatio: policy.thresholdRatio, + thresholdTokens, + retainTokens, + summarizationProvider: policy.summarizationProvider, + summarizationModel: policy.summarizationModel, + maxTokens: policy.maxTokens, + compactionRetries: policy.compactionRetries, + maxOverflowRetries: policy.maxOverflowRetries, + }) } -function assertPositiveInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`) +/** Choose an explicit retention form or inherit the already-resolved fallback. */ +function resolveRetention( + config: CompactPolicyConfig, + fallback: ResolvedRetention, +): ResolvedRetention { + if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens } + if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio } + return fallback +} + +/** Reject a capacity-independent retention conflict at plugin load. */ +function validateRatioRetention( + thresholdRatio: number, + retention: ResolvedRetention, + name: string, +): void { + if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) { + throw new Error( + `${name}: retainRatio (${retention.retainRatio}) must be less than ` + + `the resolved thresholdRatio (${thresholdRatio})`, + ) } } -function assertNonNegativeInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value < 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`) +/** Validate, detach, and reject duplicate exact-target policies. */ +function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] { + if (configured === undefined) return [] + if (!Array.isArray(configured)) { + throw new Error('BasicCompactConfig: modelPolicies must be an array') + } + const seen = new Set() + return configured.map((source: unknown, index) => { + const name = `BasicCompactConfig: modelPolicies[${index}]` + assertModelPolicy(source, name) + const key = `${source.provider}\u0000${source.model}` + if (seen.has(key)) { + throw new Error( + `BasicCompactConfig: duplicate model policy for ${source.provider}/${source.model}`, + ) + } + seen.add(key) + return { ...source } + }) +} + +/** Validate one untrusted exact-target override and narrow its public type. */ +function assertModelPolicy( + source: unknown, + name: string, +): asserts source is ModelCompactPolicyConfig { + if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`) + validateKeys(source, MODEL_POLICY_KEYS, name) + assertNonEmptyString(`${name}.provider`, source.provider) + assertNonEmptyString(`${name}.model`, source.model) + validatePolicy(source, name) +} + +/** Validate the fields common to defaults and exact-target partial overrides. */ +function validatePolicy( + config: CompactPolicyConfig | Record, + name: string, +): void { + const thresholdRatio = config.thresholdRatio + const retainRatio = config.retainRatio + const retainTokens = config.retainTokens + const maxTokens = config.maxTokens + const compactionRetries = config.compactionRetries + const maxOverflowRetries = config.maxOverflowRetries + if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio) + if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio) + if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens) + if (retainRatio !== undefined && retainTokens !== undefined) { + throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`) + } + if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens) + if (compactionRetries !== undefined) { + assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries) + } + if (maxOverflowRetries !== undefined) { + assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries) + } + + const summarizationProvider = config.summarizationProvider + const summarizationModel = config.summarizationModel + if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') { + throw new Error(`${name}.summarizationProvider must be a string`) + } + if (summarizationModel !== undefined && typeof summarizationModel !== 'string') { + throw new Error(`${name}.summarizationModel must be a string`) + } + if (((summarizationProvider ?? '').length === 0) + !== ((summarizationModel ?? '').length === 0)) { + throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`) } } -function assertRatio(name: string, value: number): void { +/** Reject stale or misspelled keys before defaults can hide them. */ +function validateKeys(config: object, keys: ReadonlySet, name: string): void { + for (const key of Object.keys(config)) { + if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`) + } +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertNonEmptyString(name: string, value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`) + } +} + +function assertPositiveInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new Error(`${name} (${String(value)}) must be a positive integer`) + } +} + +function assertNonNegativeInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`${name} (${String(value)}) must be a non-negative integer`) + } +} + +function assertRatio(name: string, value: unknown): asserts value is number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`) + throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`) } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 5d325d57ba..1e9af13888 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,27 +10,76 @@ import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { resolveConfig } from './config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, + TargetPressureConfigError, +} from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' import type { BasicCompactConfig, + ModelCompactPolicyConfig, ResolvedConfig, } from './types.ts' export type { BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, } from './types.ts' -/** Resolve the exact model durably routed for the latest provider request. */ -function routedModel(session: Session): string | undefined { - const model = session.requestHeader()?.config.model - return model === undefined || model.length === 0 ? undefined : model +/** Resolve the exact provider/model durably routed for the latest request. */ +function routedTarget( + session: Session, +): Pick | undefined { + const config = session.requestHeader()?.config + if (config === undefined || config.provider.length === 0 || config.model.length === 0) { + return undefined + } + return { provider: config.provider, model: config.model } } +/** Resolve the conversation target used to select an optional policy override. */ +function conversationTarget( + agent: Agent, +): Pick | undefined { + const routed = routedTarget(agent.session) + if (routed !== undefined) return routed + if (agent.options.provider === undefined || agent.options.provider.length === 0 + || agent.options.model === undefined || agent.options.model.length === 0) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +const thresholdRatioSchema = z.number() +const retainRatioSchema = z.number() +const retainTokensSchema = z.number().step(1).min(0) +const summarizationProviderSchema = z.string() +const summarizationModelSchema = z.string() +const maxTokensSchema = z.number().step(1).min(1) +const compactionRetriesSchema = z.number().step(1).min(0) +const maxOverflowRetriesSchema = z.number().step(1).min(0) + +const modelPolicy: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, +}) + /** * Dependency-light compaction backend using `ctx.tokenMeter` for pressure, * retention, provenance, and summary-convergence pricing. @@ -43,22 +92,26 @@ export class BasicCompactService extends CompactService { static inject = ['llm', 'tokenMeter'] static Config: z = z.object({ - thresholdRatio: z.number().default(0.8), - retainTokens: z.number().step(1), - summarizationProvider: z.string().default(''), - summarizationModel: z.string().default(''), - maxTokens: z.number().step(1).min(1).default(8192), - compactionRetries: z.number().step(1).min(0).default(1), - maxOverflowRetries: z.number().step(1).min(0).default(1), - auto: z.boolean().default(true), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, + modelPolicies: z.array(modelPolicy), + auto: z.boolean(), }) /** Resolved and validated compaction configuration. */ readonly config: ResolvedConfig + private readonly warnedPressureConfigTargets = new Set() + constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) - this.config = resolveConfig(config, ctx.tokenMeter) + this.config = resolveConfig(config) if (this.config.auto) this._registerAutomaticCompaction() } @@ -88,15 +141,21 @@ export class BasicCompactService extends CompactService { const result = await this.compactIfNeeded(agent, 'pressure', signal) if (result !== null) logResult(result, 'post-step pressure') } catch (error: unknown) { + if (error instanceof TargetPressureConfigError) { + if (this.warnedPressureConfigTargets.has(error.targetKey)) return + this.warnedPressureConfigTargets.add(error.targetKey) + } const message = error instanceof Error ? error.message : String(error) ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) } }) ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { - if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || retryAttempt >= this.config.maxOverflowRetries - || signal.aborted) return next() + if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() + const target = routedTarget(agent.session) + if (target === undefined) return next() + const policy = resolveTargetPolicy(this.config, target) + if (retryAttempt >= policy.maxOverflowRetries) return next() let generation: number let result: CompactionResult | null @@ -131,7 +190,11 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return summarizeWithLlm(this.ctx, this.config, text, agent, signal) + const target = conversationTarget(agent) + const config = target === undefined + ? this.config + : resolveTargetPolicy(this.config, target) + return summarizeWithLlm(this.ctx, config, text, agent, signal) } /** @@ -149,8 +212,9 @@ export class BasicCompactService extends CompactService { trigger: CompactionTrigger, signal: AbortSignal, ): Promise { - const model = routedModel(agent.session) - if (model === undefined) return null + const target = routedTarget(agent.session) + if (target === undefined) return null + const policy = resolveTargetPolicy(this.config, target) const meter = this.ctx.tokenMeter switch (trigger) { case 'context-overflow': { @@ -166,13 +230,22 @@ export class BasicCompactService extends CompactService { assertNever(trigger, 'compaction trigger') } - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) + const context = await this.ctx.llm.resolveModelContext(target.provider, target.model) + const targetKey = `${target.provider}/${target.model}` + if (context === undefined) { + throw new TargetPressureConfigError( + targetKey, + `compact-basic: no context capacity for ${targetKey}; ` + + 'configure contextWindow on that adapter model', + ) + } + const spec = resolveCompactSpec(policy, context.contextWindow) let measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return null + if (measurement.totalTokens < spec.thresholdTokens) return null let result: CompactionResult | null = null - for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) { - const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens) + for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) { + const range = selectCompactableRange(agent.session, measurement, spec.retainTokens) if (range === null) { /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null @@ -181,12 +254,12 @@ export class BasicCompactService extends CompactService { } result = await this.compactRegion(range.start, range.end, agent, signal) measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return result + if (measurement.totalTokens < spec.thresholdTokens) return result } throw new Error( - `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` - + `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`, + `compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts ` + + `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`, ) } diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 62b5f5f5e2..7a4de4223d 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -8,7 +8,12 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ResolvedConfig } from './types.ts' + +interface SummaryConfig { + readonly summarizationProvider: string + readonly summarizationModel: string + readonly maxTokens: number +} /** Tags wrapping the structured summary inside the landed checkpoint node. */ const SUMMARY_OPEN_TAG = '' @@ -74,7 +79,7 @@ export interface SummaryResult { */ export async function summarizeWithLlm( ctx: Context, - config: ResolvedConfig, + config: SummaryConfig, text: string, agent: Agent, signal?: AbortSignal, diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 6ed0165226..c322f508ac 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -4,15 +4,19 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -20,18 +24,53 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string +} + +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } -/** Validated and detached compaction configuration. */ -export interface ResolvedConfig { +/** Exactly one validated retention form. */ +export type ResolvedRetention = + | { readonly retainRatio: number; readonly retainTokens?: never } + | { readonly retainRatio?: never; readonly retainTokens: number } + +/** Validated policy fields shared before and after exact-target matching. */ +interface ResolvedPolicyFields { readonly thresholdRatio: number - readonly retainTokens: number readonly summarizationProvider: string readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number readonly maxOverflowRetries: number +} + +/** Validated immutable config whose target-specific defaults remain unresolved. */ +export type ResolvedConfig = ResolvedPolicyFields & ResolvedRetention & { + readonly modelPolicies: readonly Readonly[] readonly auto: boolean } + +/** Fully merged policy for one routed conversation target, before capacity scaling. */ +export type ResolvedTargetPolicy = ResolvedPolicyFields & ResolvedRetention & { + readonly target: Pick +} + +/** One routed model's concrete pressure and retention budget. */ +export type ResolvedCompactSpec = Omit & { + readonly contextWindow: number + readonly thresholdTokens: number + readonly retainTokens: number +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..39d807e9c1 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,10 +4,14 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, +} from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmModelContext, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -15,9 +19,40 @@ import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' +class ContextAdapter extends LlmAdapter { + constructor(private readonly contextWindow: number) { + super() + } + + override resolveModelContext(): Promise { + return Promise.resolve({ contextWindow: this.contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +class RoutedContextAdapter extends LlmAdapter { + constructor(private readonly windows: Readonly>) { + super() + } + + override resolveModelContext(provider: string): Promise { + const contextWindow = this.windows[provider] + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + function createContext(contextWindow = 1_000): Context { const ctx = new Context() - void new TokenMeterService(ctx, { contextWindow }) + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx } @@ -140,43 +175,96 @@ async function compactIfNeeded( describe('compact configuration and defaults', () => { it('uses low-friction service-wide defaults', () => { - const ctx = createContext() - const resolved = resolveConfig({}, ctx.tokenMeter) + const resolved = resolveConfig({}) expect(resolved).toEqual({ thresholdRatio: 0.8, - retainTokens: 160, + retainRatio: 0.16, summarizationProvider: '', summarizationModel: '', maxTokens: 8192, compactionRetries: 1, maxOverflowRetries: 1, + modelPolicies: [], auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) }) it('resolves threshold and retention overrides independently', () => { - const ctx = createContext() const thresholdOnly = resolveConfig({ thresholdRatio: 0.5, - }, ctx.tokenMeter) + }) expect(thresholdOnly).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 160, + retainRatio: 0.16, }) const retentionOnly = resolveConfig({ retainTokens: 70, - }, ctx.tokenMeter) + }) expect(retentionOnly).toMatchObject({ thresholdRatio: 0.8, retainTokens: 70, }) + expect(retentionOnly).not.toHaveProperty('retainRatio') + }) + + it('merges exact provider/model policy overrides and scales ratios per model', () => { + const config = resolveConfig({ + thresholdRatio: 0.8, + retainRatio: 0.1, + modelPolicies: [{ + provider: 'small-provider', + model: 'shared-id', + thresholdRatio: 0.5, + retainTokens: 120, + }], + }) + const small = resolveTargetPolicy(config, { + provider: 'small-provider', + model: 'shared-id', + }) + const otherProvider = resolveTargetPolicy(config, { + provider: 'large-provider', + model: 'shared-id', + }) + + expect(resolveCompactSpec(small, 1_000)).toMatchObject({ + thresholdTokens: 500, + retainTokens: 120, + }) + expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({ + thresholdTokens: 1_600, + retainTokens: 200, + }) + + const ratioOverride = resolveTargetPolicy(resolveConfig({ + retainTokens: 200, + modelPolicies: [{ + provider: 'ratio-provider', + model: 'ratio-model', + thresholdRatio: 0.6, + retainRatio: 0.2, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }], + }), { provider: 'ratio-provider', model: 'ratio-model' }) + expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({ + thresholdTokens: 1_200, + retainTokens: 400, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }) }) it('validates common values and pressure-policy invariants', () => { - const ctx = createContext() const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], @@ -184,19 +272,48 @@ describe('compact configuration and defaults', () => { [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ summarizationProvider: MODEL }, /must both be set or both be empty/], - [{ summarizationModel: MODEL }, /must both be set or both be empty/], + [{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/], + [{ summarizationModel: MODEL }, /must both be empty or both be non-empty/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], + [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], + [{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/], [{ retainTokens: -1 }, /non-negative integer/], - [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/], + [{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/], + [{ modelPolicies: {} }, /modelPolicies must be an array/], + [{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, + /modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/, + ], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] }, + /modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/, + ], + [{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/], [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/], [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { - expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) + expect(() => resolveConfig(config as BasicCompactConfig)).toThrow(pattern) } + + const invalidPressure = resolveTargetPolicy(resolveConfig({ + thresholdRatio: 0.5, + retainTokens: 500, + }), { provider: MODEL, model: MODEL }) + expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/) + expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/) + expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/) }) }) @@ -216,7 +333,7 @@ describe('pressure measurement and retention', () => { expect(compact.calls).toHaveLength(0) }) - it('meters any routed model without profile resolution', async () => { + it('meters an unlisted model when its provider adapter supplies context metadata', async () => { const compact = service(compactConfig) const session = conversation() session.append('request/header', { @@ -227,6 +344,52 @@ describe('pressure measurement and retention', () => { .resolves.not.toBeNull() }) + it('re-resolves capacity after a same-model-id provider switch in one session', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ + large: 10_000, + small: 1_000, + })) + const compact = service({ + auto: false, + thresholdRatio: 0.5, + retainRatio: 0.1, + }, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'large', model: 'shared-id' } }, + reason: 'resume', + }) + await expect(compactIfNeeded(compact, session)).resolves.toBeNull() + + session.append('request/header', { + header: { config: { provider: 'small', model: 'shared-id' } }, + reason: 'change', + }) + await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull() + }) + + it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + const compact = service(compactConfig, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'unknown-context', model: 'model' } }, + reason: 'resume', + }) + + await expect(compactIfNeeded(compact, session, 'pressure')) + .rejects.toThrow(/no context capacity for unknown-context\/model/) + await expect(compactIfNeeded(compact, session, 'context-overflow')) + .resolves.not.toBeNull() + }) + it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { const compact = service(compactConfig) const session = new Session(SessionId('single-tool-pair')) @@ -678,7 +841,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) - void new TokenMeterService(ctx, { contextWindow: 1_000 }) + void new TokenMeterService(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) const compact = new ExposedCompactService(ctx, config) @@ -761,6 +924,31 @@ describe('default one-shot summarizer', () => { .rejects.toThrow(/no provider\/model available for summarization/) }) + it('uses a complete AgentOptions target when no durable route exists', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const session = new Session(SessionId('headerless-summary')) + + await expect(compact.runSummarize('history', agent(session, MODEL))).resolves.toMatchObject({ + provider: MODEL, + model: MODEL, + }) + expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL }) + }) + + it.each([ + { provider: '', model: MODEL }, + { provider: MODEL }, + { provider: MODEL, model: '' }, + ])('rejects incomplete AgentOptions target %#', async (options) => { + const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) + const owner = { + session: new Session(SessionId(`incomplete-${String(options.model)}`)), + options, + } as Agent + await expect(compact.runSummarize('history', owner)) + .rejects.toThrow(/no provider\/model available for summarization/) + }) + it.each([ [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], @@ -857,6 +1045,43 @@ describe('automatic listener and loader composition', () => { expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) + it('warns once per routed target when proactive pressure has no context metadata', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 180, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), + ]) + }) + + it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 500, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), + ]) + }) + it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { const ctx = createContext(10_000) void new TestCompactService(ctx, { @@ -989,6 +1214,18 @@ describe('automatic listener and loader composition', () => { .toEqual({ action: 'retry' }) }) + it('delegates canonical overflow when no durable routed target exists', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + const session = new Session(SessionId('headerless-overflow')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + + await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' }) + }) + it('honors retry caps, non-context failures, and cancellation', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) @@ -1051,7 +1288,6 @@ describe('automatic listener and loader composition', () => { const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) - expect(ctx.tokenMeter.contextWindow).toBe(128_000) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await compactFiber.dispose() expect(ctx.get('compact')).toBeUndefined() @@ -1062,7 +1298,7 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService, { contextWindow: 1_000 }) + await ctx.plugin(TokenMeterService) const fiber = await ctx.plugin(TestCompactService, { thresholdRatio: 0.5, retainTokens: 180, diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 1327f073c5..e73fd82687 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -37,6 +37,10 @@ class StepwiseToolAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 400 }) + } + async * stream(_options: GenerateOptions): AsyncIterable { const n = this.calls this.calls += 1 @@ -65,6 +69,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 128 }) + } + override async * stream(options: GenerateOptions): AsyncIterable { if (options.system?.includes('You are a compaction engine')) { this.summaryRequests.push(options) @@ -100,7 +108,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 400 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -225,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) await ctx.plugin(BasicCompactService, { diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index b13e8f8b67..be630919bd 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -54,12 +54,10 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-token-meter'", - ' config:', - ' contextWindow: 4096', "- name: '@deepseek-ai/dsh-compact-basic'", ' config:', ' thresholdRatio: 0.5', - ' retainTokens: 512', + ' retainRatio: 0.125', ' auto: false', ]) @@ -67,11 +65,10 @@ describe('real Loader composition', () => { .filter(entry => entry.fiber === undefined && !entry.disabled) .map(entry => entry.options.name) expect(unloaded).toEqual([]) - expect(loaded.tokenMeter.contextWindow).toBe(4096) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) expect((loaded.compact as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 512, + retainRatio: 0.125, auto: false, }) }) @@ -79,8 +76,8 @@ describe('real Loader composition', () => { it('rejects stale token-meter config after Schemastery normalization', async () => { context = new Context() await expect(context.plugin(TokenMeterService, { - models: { legacy: { contextWindow: 4096 } }, - } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/) + contextWindow: 4096, + })).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -91,4 +88,18 @@ describe('real Loader composition', () => { models: { legacy: { thresholdRatio: 0.5 } }, } as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/) }) + + it('rejects a capacity-independent merged ratio conflict during plugin load', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + retainRatio: 0.2, + modelPolicies: [{ + provider: 'test-provider', + model: 'test-model', + thresholdRatio: 0.1, + }], + })).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/) + }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..3699463d91 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -266,6 +266,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', }, + { + signature: 'async resolveModelContext( provider: string, model: string, ): Promise', + jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */', + }, { signature: 'stream(options: GenerateOptions): AsyncIterable', jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', @@ -1184,6 +1188,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmModelContext', + declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', diff --git a/packages/llm/README.md b/packages/llm/README.md index ac08ffafc2..2e239b3cb2 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,4 +9,4 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. +The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The same route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 27bf4b626a..5dfdf954f2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -19,11 +19,15 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek V4 Flash + contextWindow: 128000 - id: private-reasoner description: Company-hosted reasoning model + contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. +The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. + +`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns it only for an exact configured id; omission or an unlisted pass-through model returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. `reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default). diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 918c8eee82..da780fff45 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -6,7 +6,13 @@ */ import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -21,6 +27,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } /** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ @@ -79,6 +87,14 @@ export class DeepSeekAdapter extends LlmAdapter { }))) } + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + async * stream(options: GenerateOptions): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f9f223b6ff..c066ed026a 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -20,8 +20,8 @@ export const name = 'llm-deepseek' export const inject = ['llm'] const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash' }, - { id: 'deepseek-v4-pro' }, + { id: 'deepseek-v4-flash', contextWindow: 128_000 }, + { id: 'deepseek-v4-pro', contextWindow: 128_000 }, ] /** @@ -47,6 +47,7 @@ const catalogModel: z = z.object({ id: z.string().required(), name: z.string(), description: z.string(), + contextWindow: z.number().step(1).min(1), }) export const Config: z = z.object({ @@ -68,12 +69,19 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) } + if (model.contextWindow !== undefined + && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { id: model.id, ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, + ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 954a0ecebd..fb8fbcb872 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -312,6 +312,8 @@ describe('plugin registration and config', () => { { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) + .resolves.toEqual({ contextWindow: 128_000 }) }) it('uses the default model catalog when apply is called directly', async () => { @@ -331,14 +333,23 @@ describe('plugin registration and config', () => { apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [ - { id: 'private-fast' }, - { id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + }, ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) + .resolves.toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted')) + .resolves.toBeUndefined() }) it('allows an explicit empty model catalog', async () => { @@ -355,6 +366,8 @@ describe('plugin registration and config', () => { it.each([ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], ] as const)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() @@ -367,6 +380,19 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + expect(() => { + LlmDeepSeek.apply(ctx, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 06395d701c..2146190b26 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -28,7 +28,7 @@ Configure credentials and deployment-specific transport settings per provider. O Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. +The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7f40c67da3..c284ba3720 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -15,7 +15,7 @@ import type { SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import type { PiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' @@ -87,6 +87,22 @@ export class PiAiAdapter extends LlmAdapter { }))) } + override resolveModelContext( + provider: string, + model: string, + ): Promise { + const profile = this.profiles.get(provider) + if (profile === undefined) { + return Promise.reject(new LlmError( + `pi-ai adapter does not own provider "${provider}"`, + 'NO_ADAPTER', + )) + } + return Promise.resolve().then(() => ({ + contextWindow: resolveModel(profile, model).contextWindow, + })) + } + async * stream(options: GenerateOptions): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51f78e7760..dfccc134d2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -260,6 +260,9 @@ describe('provider profile lifecycle', () => { provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', }) expect(models.every(model => model.provider === 'openai')).toBe(true) + const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') + expect(context).toBeDefined() + expect(typeof context?.contextWindow).toBe('number') }) it('accepts absent credentials for pi-ai ambient authentication', async () => { @@ -296,6 +299,10 @@ describe('provider profile lifecycle', () => { it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4')) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model')) + .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) await expect((async () => { for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ } })()).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index f9142dbc52..7d922fbdfe 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -11,12 +11,15 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. +- `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. + ### Events | Event | Mode | Purpose | @@ -25,7 +28,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. ### Content-block vocabulary (`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f276aa9f92..5f53e44710 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,14 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + Message, + StreamChunk, +} from './types.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' @@ -82,6 +89,20 @@ export abstract class LlmAdapter { return Promise.resolve([]) } + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve(undefined) + } + /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. @@ -177,6 +198,29 @@ export class LlmService extends Service { }) } + /** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ + async resolveModelContext( + provider: string, + model: string, + ): Promise { + const context = await this.registration(provider).adapter.resolveModelContext(provider, model) + if (context === undefined) return undefined + if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) { + throw new LlmError( + `adapter returned invalid context metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_CONTEXT', + ) + } + return { contextWindow: context.contextWindow } + } + private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b8054c2046..b95ad95c7f 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -141,6 +141,12 @@ export interface LlmModelInfo { description?: string } +/** Provider-owned context capacity for one exact provider/model route. */ +export interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..952d3e981c 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -9,7 +9,7 @@ import LlmService, { LlmError, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -44,6 +44,7 @@ class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], + private readonly contexts: Readonly> = {}, ) { super(SCRIPT) } @@ -55,6 +56,13 @@ class CatalogAdapter extends ScriptedAdapter { override listModels(_provider: string): Promise { return Promise.resolve(this.models) } + + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + return Promise.resolve(this.contexts[model]) + } } const SCRIPT: StreamChunk[] = [ @@ -439,8 +447,42 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }]) await expect(ctx.llm.listModels('plain')).resolves.toEqual([]) await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) }) + it('resolves detached model context independently of advisory catalog membership', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const source = { contextWindow: 32_000 } + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { unlisted: source }, + )) + + const resolved = await ctx.llm.resolveModelContext('route', 'unlisted') + expect(resolved).toEqual({ contextWindow: 32_000 }) + source.contextWindow = 64_000 + expect(resolved).toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined() + }) + + it.each([0, -1, 1.5, Number.NaN])( + 'rejects invalid adapter model context %s', + async (contextWindow) => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { model: { contextWindow } }, + )) + await expect(ctx.llm.resolveModelContext('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' }) + }, + ) + it.each([ [{ id: 1, name: 'Name' }, 'non-string id'], [{ id: 'other', name: 'Name' }, 'mismatched id'], diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 20539de6a4..18f828ddd4 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -4,11 +4,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I ## Configuration -| Key | Default | Contract | -|---|---:|---| -| `contextWindow` | `128000` | Positive integer service-wide context capacity. | - -The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected. +The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`. ## Measurement contract @@ -30,13 +26,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket - name: '@deepseek-ai/dsh-compact-basic' ``` -Both plugins have usable defaults. A deployment with a different capacity configures the meter once: - -```yaml -- name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 32768 -``` +Both plugins have usable defaults. The meter remains independent of model routing and optional compaction. A deployment configures capacity on its LLM adapter and compaction policy on `dsh-compact-basic`. ## Model Experience diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 0c18dee74b..2fdfb064db 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -19,12 +19,6 @@ import type { export type * from './types.ts' -/** Default service-wide provider context capacity. */ -const DEFAULT_CONTEXT_WINDOW = 128_000 - -/** Complete public configuration key set. */ -const TOKEN_METER_CONFIG_KEYS: ReadonlySet = new Set(['contextWindow']) - /** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 @@ -74,28 +68,10 @@ function optionalHeaderEquals( /** Reject stale or misspelled keys before defaults can hide them. */ function validateConfigKeys(config: TokenMeterConfig): void { for (const key of Object.keys(config)) { - if (!TOKEN_METER_CONFIG_KEYS.has(key)) { - throw new Error( - `TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`, - ) - } + throw new Error(`TokenMeterConfig: unknown key "${key}" (no settings are supported)`) } } -/** Resolve and validate the one service-wide context capacity. */ -function resolveContextWindow(config: TokenMeterConfig): number { - validateConfigKeys(config) - const contextWindow = config.contextWindow === undefined - ? DEFAULT_CONTEXT_WINDOW - : config.contextWindow - if (!Number.isInteger(contextWindow) || contextWindow <= 0) { - throw new Error( - `TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`, - ) - } - return contextWindow -} - declare module 'cordis' { interface Context { tokenMeter: TokenMeterService @@ -104,18 +80,13 @@ declare module 'cordis' { /** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { - static Config: z = z.object({ - contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), - }) - - /** Provider context-window capacity used by pressure consumers. */ - readonly contextWindow: number + static Config: z = z.object({}) private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') - this.contextWindow = resolveContextWindow(config) + validateConfigKeys(config) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 7fb35af997..33aa3d4bb5 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,11 +6,8 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object /** The baseline from which a signed surface delta produces current pressure. */ export type TokenMeasurementBaseline = diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e7ddca6696..dc7d0c6e2e 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -87,29 +87,13 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void { } describe('TokenMeterService configuration and registration', () => { - it('provides one zero-config context window', () => { - const service = meter() - expect(service.contextWindow).toBe(128_000) - }) - - it('accepts one service-wide context-window override', () => { - expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000) - }) - - it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => { - expect(() => meter({ [key]: {} })) - .toThrow(`TokenMeterConfig: unknown key "${key}"`) - }) - - it.each([ - { contextWindow: 0 }, - { contextWindow: -1 }, - { contextWindow: 1.5 }, - { contextWindow: Number.NaN }, - { contextWindow: null }, - ] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => { - expect(() => meter(config)).toThrow(/contextWindow .* positive integer/) - }) + it.each(['models', 'contextWindow', 'contextWidow'])( + 'rejects stale or unknown top-level config key %s', + (key) => { + expect(() => meter({ [key]: {} })) + .toThrow(`TokenMeterConfig: unknown key "${key}"`) + }, + ) it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { const ctx = new Context() @@ -123,7 +107,7 @@ describe('TokenMeterService configuration and registration', () => { describe('TokenMeterService pricing', () => { it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => { - const service = meter({ contextWindow: 100 }) + const service = meter() const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, @@ -331,7 +315,7 @@ describe('replay anchors and surface folds', () => { }) it('keeps only the latest successful request anchor across model switches', () => { - const service = meter({ contextWindow: 1_000 }) + const service = meter() const session = new Session(SessionId('switch')) const alphaHeader = header('alpha', { system: 'same envelope' }) appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..1506d97104 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -34,6 +34,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmModelContext: 'core.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', Message: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..a90c76c577 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -9,6 +9,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelContext", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..8a9142381d 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -544,7 +544,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t - `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L43) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L50) ## session/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index e8e4d53a16..da8f2f4c33 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L118) ### ctx.llm.registerAdapter(providers, adapter) @@ -29,7 +29,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L133) ### ctx.llm.listProviders() @@ -45,7 +45,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L164) ### ctx.llm.listModels(provider) @@ -65,7 +65,30 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L153) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L174) + +### ctx.llm.resolveModelContext(provider, model) + +```ts website-api +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise +``` + +Resolve context capacity from the adapter that owns one exact route. This query is independent of the advisory model catalog: an unlisted model may return metadata, while `undefined` never rejects later routing. + +- `provider` — registered provider route to inspect. +- `model` — exact model id passed to the adapter. + +**Returns** detached context metadata, or `undefined` when the adapter has none. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L209) ### ctx.llm.stream(options) @@ -91,4 +114,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L308) diff --git a/website/zh-CN/api/harness/token-meter.md b/website/zh-CN/api/harness/token-meter.md index 30b83f79d4..26ffb24b1a 100644 --- a/website/zh-CN/api/harness/token-meter.md +++ b/website/zh-CN/api/harness/token-meter.md @@ -6,18 +6,7 @@ Replay owner for one service-wide estimator and isolated per-session folds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L106) - -### ctx.tokenMeter.contextWindow - -```ts website-api -/** Provider context-window capacity used by pressure consumers. */ -readonly contextWindow: number -``` - -Provider context-window capacity used by pressure consumers. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L82) ### ctx.tokenMeter.measure(session, requestHeader?) @@ -50,7 +39,7 @@ Provider usage is reused only when the latest successful call's canonical reques **Returns** a detached deeply immutable pressure and surface measurement. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L114) ### ctx.tokenMeter.estimateMessage(message) @@ -69,4 +58,4 @@ Heuristically price one model-visible message. **Returns** content and role-framing tokens under the fixed service heuristic. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L181) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L152) diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index 35fb42185b..9c5acb415e 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -52,15 +52,17 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 # LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 # `!!js` 从环境变量读取密钥,不会写进配置文件 -# `models` 声明该适配器能处理哪些模型名 +# `models` 提供可选模型目录,并为精确模型声明上下文容量 - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-pro - - deepseek-v4-flash + - id: deepseek-v4-pro + contextWindow: 128000 + - id: deepseek-v4-flash + contextWindow: 128000 # Bash 执行器:让 Agent 能跑 shell 命令 # timeoutMs 设置单条命令的超时时间 @@ -84,11 +86,9 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. -# Token 计量:统一定义模型能看到的 token 上限 +# Token 计量:从持久日志计算请求压力;模型容量由上面的适配器提供 - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 128000 # 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 # thresholdRatio 超过这个比例就触发压缩 @@ -97,7 +97,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 @@ -243,7 +243,7 @@ config: |------|------|--------|------| | `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | | `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | -| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | +| `models` | object[] | V4 Flash 与 V4 Pro(各 `128000` token) | 建议模型目录;每项包含 `id`,可选 `name`、`description` 与 `contextWindow` | | `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | | `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | @@ -265,14 +265,16 @@ config: | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | -| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | -| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | -| `maxTokens` | number | **必填** | 总结时的最大输出 token | +| `thresholdRatio` | number | `0.8` | token 占用超过路由模型容量的此比例时触发压缩(0-1) | +| `retainRatio` | number | `0.16` | 压缩后按路由模型容量比例保留近期内容;与 `retainTokens` 互斥 | +| `retainTokens` | number | — | 按绝对 token 数保留近期内容;与 `retainRatio` 互斥 | +| `summarizationProvider` | string | `''`(使用当前提供方) | 专门用于总结的提供方;与 `summarizationModel` 同时设置 | | `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | -| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | -| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | -| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | +| `maxTokens` | number | `8192` | 总结时的最大输出 token | +| `compactionRetries` | number | `1` | 首次压缩后仍超标时的额外重试次数 | +| `maxOverflowRetries` | number | `1` | 提供方确认上下文溢出后的最大恢复重试次数 | +| `modelPolicies` | object[] | `[]` | 按精确 `{ provider, model }` 组合部分覆盖上述策略 | +| `auto` | boolean | `true` | 是否在每个成功步骤后检查压力,并处理规范化上下文溢出 | ### fs-local(文件系统) From 734a45cf66149c474556480ee916747af0d777bb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 18:36:21 +0800 Subject: [PATCH 23/48] ci: deploy documentation to GitHub Pages --- ...026-07-13-documentation-site-projection.md | 6 +- .github/workflows/docs-pages.yml | 84 +++++++++++++++++++ website/.vitepress/config.ts | 1 + 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs-pages.yml diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index b3bdbb0c1b..44d1b17a5b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -20,7 +20,7 @@ The projector parses Markdown links without reserializing the document. A link t Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. -Site publication is separate from site construction. The repository contains local development and build commands, but no hosting or deployment workflow until a public destination is chosen. +Site publication remains separate from site construction. A dedicated GitHub Actions workflow runs the existing documentation gates, uploads `website/.dist` as a Pages artifact, and deploys only after the build succeeds. `actions/configure-pages` supplies the destination's base path to VitePress at build time, so the private Pages origin, a later public project path, and a custom domain do not require distinct checked-in configurations. Pages visibility remains a repository hosting setting rather than a workflow permission. ## Alternatives considered @@ -34,8 +34,10 @@ Site publication is separate from site construction. The repository contains loc **Build only in a deployment workflow.** A deployment job can reveal rendering failures after merge. Keeping the production build in `doc-sync` makes the same failure visible locally and in ordinary CI even when no public deployment exists. +**Hard-code the public project path.** A fixed `/deepseek-harness/` base works for the public project URL but not for the unique origin assigned to a private Pages site or for a future custom domain. Consuming Pages metadata keeps one build contract across those destinations. + ## Consequences -Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point. The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000000..41e003ae8a --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,84 @@ +name: Deploy documentation + +on: + push: + # Keep this branch only for the pre-merge deployment validation run. + branches: [master, worktree/docs-pages-deploy] + paths: + - '.github/workflows/docs-pages.yml' + - 'docs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'scripts/project-doc-site.ts' + - 'scripts/project-doc-site.spec.ts' + - 'website/**' + workflow_dispatch: + +concurrency: + group: github-pages + cancel-in-progress: false + +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pages: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Verify and build documentation + env: + DOCS_BASE: ${{ steps.pages.outputs.base_path }}/ + run: pnpm run doc-sync + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: website/.dist + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b9f38b0f7e..3ea513a0bb 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -108,6 +108,7 @@ const sharedTheme: Pick Date: Mon, 20 Jul 2026 18:39:37 +0800 Subject: [PATCH 24/48] fix(invariants): require justified empty companions --- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 9 ++-- ...7-19-package-owned-invariant-service.zh.md | 9 ++-- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 2 +- packages/bash/bash-sandbox/src/invariant.ts | 2 +- packages/bash/bash/src/invariant.ts | 2 +- packages/bash/tool-bash/src/invariant.ts | 2 +- .../code-runtime-worker/src/invariant.ts | 2 +- .../code-runtime/src/invariant.ts | 2 +- .../compact/compact-basic/src/invariant.ts | 2 +- packages/compact/compact/src/invariant.ts | 2 +- .../context/time-context/src/invariant.ts | 2 +- .../workspace-context/src/invariant.ts | 2 +- packages/cordis/tool-cordis/src/invariant.ts | 2 +- packages/core/system-prompt/src/invariant.ts | 2 +- packages/core/tools/src/invariant.ts | 2 +- packages/examples/acp-demo/src/invariant.ts | 2 +- .../agent-spine-demo/src/invariant.ts | 2 +- packages/examples/cli-demo/src/invariant.ts | 2 +- .../examples/jsonrpc-demo/src/invariant.ts | 2 +- packages/examples/stdio-demo/src/invariant.ts | 2 +- packages/fs/fs-local/src/invariant.ts | 2 +- packages/fs/fs-policy/src/invariant.ts | 2 +- packages/fs/fs/src/invariant.ts | 2 +- packages/fs/tool-fs-search/src/invariant.ts | 2 +- packages/fs/tool-fs/src/invariant.ts | 2 +- .../guard/repeat-tool-guard/src/invariant.ts | 2 +- packages/hooks/hook-protocol/src/invariant.ts | 2 +- packages/hooks/hooks-claude/src/invariant.ts | 2 +- packages/hooks/hooks-codex/src/invariant.ts | 2 +- packages/llm/llm-deepseek/src/invariant.ts | 2 +- packages/llm/llm-pi-ai/src/invariant.ts | 2 +- packages/llm/llm/src/invariant.ts | 2 +- packages/llm/token-meter/src/invariant.ts | 2 +- packages/mcp/mcp-client/src/invariant.ts | 2 +- .../sandbox/sandbox-local/src/invariant.ts | 2 +- packages/sandbox/sandbox/src/invariant.ts | 2 +- packages/sdk/create-sdk/src/invariant.ts | 2 +- packages/sdk/helper/src/invariant.ts | 2 +- packages/sdk/scripts/src/invariant.ts | 2 +- packages/sdk/telemetry/src/invariant.ts | 2 +- .../src/invariant.ts | 2 +- .../src/invariant.ts | 2 +- .../session-persistence/src/invariant.ts | 2 +- .../session-query/src/invariant.ts | 2 +- packages/skill/skill-local/src/invariant.ts | 2 +- packages/skill/skill/src/invariant.ts | 2 +- packages/skill/tool-skill/src/invariant.ts | 2 +- packages/spill/spill-local/src/invariant.ts | 2 +- packages/spill/spill-policy/src/invariant.ts | 2 +- packages/spill/spill/src/invariant.ts | 2 +- .../subagent/subagent-acp/src/invariant.ts | 2 +- .../subagent/subagent-fork/src/invariant.ts | 2 +- .../subagent-inprocess/src/invariant.ts | 2 +- .../subagent/subagent-spawn/src/invariant.ts | 2 +- .../subagent-subprocess/src/invariant.ts | 2 +- packages/subagent/subagent/src/invariant.ts | 2 +- .../subagent/tool-subagent/src/invariant.ts | 2 +- .../support/acp-snapshot/src/invariant.ts | 2 +- .../agent-loop-testkit/src/invariant.ts | 2 +- packages/support/invariants/src/invariant.ts | 2 +- packages/support/llm-replay/src/invariant.ts | 2 +- .../support/loader-smoke/src/invariant.ts | 2 +- packages/tasks/tasks/src/invariant.ts | 2 +- packages/tasks/tool-tasks/src/invariant.ts | 2 +- .../timeout/timeout-policy/src/invariant.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- packages/ui/acp/src/invariant.ts | 2 +- packages/ui/app-boot/src/invariant.ts | 2 +- packages/ui/jsonrpc/src/invariant.ts | 2 +- packages/ui/permission/src/invariant.ts | 2 +- packages/ui/stdio/src/invariant.ts | 2 +- packages/ui/tool-ask-user/src/invariant.ts | 2 +- packages/ui/tui/src/invariant.ts | 2 +- packages/ui/user-approval/src/invariant.ts | 2 +- packages/ui/user-interaction/src/invariant.ts | 2 +- packages/util/brand/src/invariant.ts | 2 +- packages/util/home/src/invariant.ts | 2 +- packages/util/paths/src/invariant.ts | 2 +- packages/util/retention/src/invariant.ts | 2 +- packages/util/timeout/src/invariant.ts | 2 +- packages/web/tool-web/src/invariant.ts | 2 +- packages/web/web-fetch-local/src/invariant.ts | 2 +- .../web/web-search-deepseek/src/invariant.ts | 2 +- packages/web/web-search-exa/src/invariant.ts | 2 +- .../web-search-perplexity/src/invariant.ts | 2 +- packages/web/web/src/invariant.ts | 2 +- .../workflow/tool-workflow/src/invariant.ts | 2 +- .../workflow-workerthread/src/invariant.ts | 2 +- packages/workflow/workflow/src/invariant.ts | 2 +- scripts/package-invariants.spec.ts | 13 +++++ scripts/package-invariants.ts | 50 ++++++++++++++++++- 93 files changed, 162 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index ef4090b3d6..4665f0ede8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: d7b77439c8cfdfdf2fc4f01e779a6d3c7f345457 -2026-07-19-package-owned-invariant-service.zh.md: b553d137f9f06b57f24672bf6984d087ca99ceb2 +2026-07-19-package-owned-invariant-service.md: 19512580db7b228fbf4c44109ccb07eeaca80b54 +2026-07-19-package-owned-invariant-service.zh.md: f7c4bc4352e3ab1cd3a5aa7d2d0a64734bc22cb2 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index d7b77439c8..19512580db 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. Checks protect observable relations in event streams or mutable runtime data; service method presence and plugin wiring are type, load, and repository-gate contracts rather than runtime invariants. A package with no plausible runtime relation uses an empty installer whose `No runtime invariant:` comment explains the absence instead of inventing a synthetic assertion. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion or records why no runtime relation exists. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. +`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, unexplained empty installers, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. ### Scoped-event semantic map @@ -91,12 +91,13 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. - **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. - **Discover every `invariant.ts` file automatically at runtime.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. Build-time generation, verification, and the test host may enumerate the source tree because they validate repository completeness rather than composing a shipped deployment. +- **Require a synthetic assertion from every package.** Rejected because checking method presence, plugin names, or fixed examples only turns repository wiring and unit-test facts into startup work. Packages without an observable event or mutable-data relation state that fact locally and keep an empty installer. - **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. +- Every package pays the small publication and dependency cost of an invariant companion, including packages whose justified empty installer has no listener. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index b553d137f9..f7c4bc4352 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。检查保护事件流或可变运行时数据中可观察的关系;服务方法是否存在以及插件接线是否正确,属于类型、加载和仓库门禁契约,不是运行时不变式。没有合理运行时关系可检查的包使用空 installer,并通过 `No runtime invariant:` 注释解释原因,而不是编造断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言或记录没有运行时关系的原因之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、没有解释的空 installer、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 ### Scoped event 语义映射 @@ -91,12 +91,13 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 - **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 - **在运行时自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。构建期生成与校验以及测试 host 可以枚举源码树,因为它们验证的是仓库完整性,而不是组合已发布的部署。 +- **要求每个包提供合成断言。** 不予采纳,因为检查方法是否存在、插件名或固定样例,只会把仓库接线与单元测试事实变成启动时工作。没有可观察事件或可变数据关系的包会在本地说明原因,并保留空 installer。 - **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 +- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括使用有理由空 installer、不添加监听器的包。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index adf1ba1cd0..f1403bb973 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant`, register its manifest name, and retain the generated baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Every package owns an invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relations; when none exists, keep an empty installer with a `No runtime invariant:` comment explaining why instead of inventing a shape or presence assertion. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 8af5f91b22..0fe56f5929 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index e74190fa82..26e0bb769e 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 350f68bfc7..9f9d4cf164 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 286089c7ff..65c124801d 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 41b3eab511..443fa67a1d 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'code-runtime-worker-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 6102927d77..1e5b5e4bb9 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'code-runtime-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index f72c0e5898..5afc935223 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'compact-basic-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index f7a2cbffd1..40547461dc 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'compact-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 64fb98ac81..c452699b59 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'time-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f3e76225c0..6a49e1bd15 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workspace-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index a3f375b69f..194ac33b1c 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-cordis-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 5117f93ce0..d281d29f2b 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'system-prompt-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 7c5743e1d4..a296415939 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tools-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index d8f2ff17dc..4050fa90d7 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 913b5ca2ab..134c5d7319 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'agent-spine-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts index 5681362dcc..35077586c4 100644 --- a/packages/examples/cli-demo/src/invariant.ts +++ b/packages/examples/cli-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'cli-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index 21f19faf0f..b59fcfd9ba 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'jsonrpc-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 5d51c3cd9b..93d5a73df7 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'stdio-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 0c89f2299b..ed93f88c72 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 11f155d2ec..60b40e074d 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 55895b4b24..79dcb9b3ee 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f0055b611a..b010f15d6c 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-fs-search-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index 7a683d978f..ecdf7237ee 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 05c4df1a66..1bf0818b72 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'repeat-tool-guard-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 9893493644..c2dac9910d 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hook-protocol-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index 5c6002f7f5..f712873f5f 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hooks-claude-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 1b8f03a057..e81eb33340 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hooks-codex-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index c3e6b5e153..8a00b0b498 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index 4ada7a1a51..a49ebab07e 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-pi-ai-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a8a56ce245..3911551916 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 00ceb567be..8fd25f6998 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'token-meter-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index d9e75e9955..92e58ec0e4 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'mcp-client-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index 3582962f94..e16cc06ead 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'sandbox-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index b220f11717..ffe9cb9db5 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 87a697e438..2f995b9029 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'create-sdk-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 63a6fc2055..873281d567 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'helper-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index fd0a0ef55b..7a637eecab 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'scripts-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 945b9e50e7..8af7848563 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'telemetry-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index 12c65db1c4..d7e8c4bb07 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-jsonl-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index a9b04cf5f5..c995336c2b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-sqlite-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index cc7cc8fa2c..bbbe4cb523 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 91bcee721e..d65f690cbc 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-query-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 475d02bb8f..1230edb9a1 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'skill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index c1d4091bb2..43abd10c43 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index abf4ba3961..97a5e04558 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index d638ffa2a9..41b4c82d1c 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index d4aa544ecd..aeae1b4583 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 714e43f3a6..0b23ef8633 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index a5828fd4c1..5cda5764a3 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index c6903fd82d..4a40e34fa4 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-fork-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 0eac204104..5ece1a32c3 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-inprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index d179a2b72a..913b2e136c 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-spawn-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 22dac32814..ee0047854f 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-subprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3a79592ee1..ab491bfb4d 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 08881e4b2b..9b38442897 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index 3579b96cf5..02a7cf0b68 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-snapshot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fc2554aa77..f299eb08cd 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'agent-loop-testkit-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index b6b4d8ae48..d55b2b73b1 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'invariants-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 50295cbedf..c6349a23b0 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-replay-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 9265a5f8b4..6728f4ab5b 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'loader-smoke-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index 468fe664b9..afc5901d92 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index fded38c895..dbb01600be 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index 9e7b5b7d4b..4a6933e8f8 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'timeout-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index a5980342f3..f925a1ad5b 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-todo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 2c081fc48c..e6ccda5342 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 498d967799..de383a5eb0 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'app-boot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index 552c312481..f3ef43b461 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'jsonrpc-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 1a774e2e4c..1c9129c916 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'permission-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 443440a215..37176f11c6 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'stdio-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index eebb1ced42..44c57bae86 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-ask-user-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index cba5fd9d2e..1f89affab4 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tui-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 73dee4f9f1..b1b90d5509 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'user-approval-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 262681fc06..bbf1b18041 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'user-interaction-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index e932e7e35e..b10caf9080 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'brand-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 5874a57c1c..f72c8ec6ae 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'home-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index c2cedfbb0d..6424d64726 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'paths-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 7516f9e5ed..2d43081596 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'retention-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 15140bb880..0a0ddb50c2 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'timeout-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 008fe2f5e1..f790931b3f 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index ef61e2a611..f7dc4579fb 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-fetch-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index 8781949be9..ac2b4e3a85 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index a2dd956625..e66d9f6847 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-exa-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index fe82c79dae..a4d370d677 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-perplexity-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index b9b1b0d45d..af5862ab5f 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index cd1f0e475b..e3f46f0a73 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6bcc40862c..ac47988dcc 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workflow-workerthread-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index b552021ca4..19e5c9e3c4 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 698969b3d7..57395cbf82 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -101,4 +101,17 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => expect(collectPackageInvariantViolations(root).map(violation => violation.message)) .toContain('generated baseline is stale; run pnpm run gen-package-invariants') }) + + it('rejects an unexplained empty package installer', () => { + const source = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const PACKAGE_NAME = '@deepseek-ai/dsh-probe' +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register(PACKAGE_NAME, install) +` + expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + .toContain('empty install function must explain why with a "No runtime invariant:" comment') + }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 45fb64829f..0078a6a4c4 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -11,6 +11,9 @@ import ts from 'typescript' /** Marker identifying baseline companions owned by this generator. */ export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' +/** Required explanation marker for an intentionally empty installer. */ +export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' + interface PackageManifest { name?: string exports?: Record @@ -78,7 +81,7 @@ export const name = '${pluginName}' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** @@ -237,6 +240,51 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + checkEmptyInstallerReason(owner, sourceFile, sourceText, violations) +} + +function checkEmptyInstallerReason( + owner: PackageInvariantOwner, + sourceFile: ts.SourceFile, + sourceText: string, + violations: PackageInvariantViolation[], +): void { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) + || declaration.name.text !== 'install' + || declaration.initializer === undefined) continue + const installer = installerFunction(declaration.initializer) + if (installer === undefined + || !ts.isBlock(installer.body) + || installer.body.statements.length > 0) return + const declarationText = sourceText.slice(statement.getFullStart(), statement.getEnd()) + if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) { + addViolation( + violations, + owner.sourcePath, + `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`, + ) + } + return + } + } +} + +function installerFunction( + initializer: ts.Expression, +): ts.ArrowFunction | ts.FunctionExpression | undefined { + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer + if (ts.isCallExpression(initializer) + && ts.isPropertyAccessExpression(initializer.expression) + && ts.isIdentifier(initializer.expression.expression) + && initializer.expression.expression.text === 'Object' + && initializer.expression.name.text === 'assign') { + const target = initializer.arguments[0] + if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target + } + return undefined } function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { From fe393c55c8986b6eb57f5fd70eb2b7fd2fc7a4e7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 18:41:10 +0800 Subject: [PATCH 25/48] ci: restrict Pages deployment to master --- .github/workflows/docs-pages.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 41e003ae8a..e1ad20997d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -2,8 +2,7 @@ name: Deploy documentation on: push: - # Keep this branch only for the pre-merge deployment validation run. - branches: [master, worktree/docs-pages-deploy] + branches: [master] paths: - '.github/workflows/docs-pages.yml' - 'docs/**' From 1145ee5fc378e1d2158d8d19719d42507558d7bc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:34:19 +0800 Subject: [PATCH 26/48] fix(invariants): assert runtime relationships, not API shapes --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 88 +++--- ...-package-invariant-runtime-contracts.zh.md | 90 +++--- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 14 +- ...7-19-package-owned-invariant-service.zh.md | 14 +- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 29 +- packages/bash/bash-sandbox/src/invariant.ts | 31 +- packages/bash/bash/src/invariant.ts | 22 +- packages/bash/bash/tests/invariant.spec.ts | 33 ++ packages/bash/tool-bash/src/invariant.ts | 35 +- .../code-runtime-worker/src/invariant.ts | 26 +- .../code-runtime/src/invariant.ts | 30 +- .../code-runtime/tests/service.spec.ts | 21 -- .../compact/compact-basic/src/invariant.ts | 47 +-- .../compact-basic/tests/compact-basic.spec.ts | 24 +- packages/compact/compact/src/invariant.ts | 100 +++++- .../compact/compact/tests/compact.spec.ts | 4 +- .../compact/compact/tests/invariant.spec.ts | 90 ++++++ .../context/time-context/src/invariant.ts | 62 +++- .../time-context/tests/invariant.spec.ts | 83 +++++ .../workspace-context/src/invariant.ts | 28 +- packages/cordis/tool-cordis/src/invariant.ts | 29 +- packages/core/system-prompt/src/invariant.ts | 49 ++- .../system-prompt/tests/invariant.spec.ts | 44 +++ packages/core/tools/src/invariant.ts | 69 ++-- packages/core/tools/tests/invariant.spec.ts | 84 +++++ packages/examples/acp-demo/src/invariant.ts | 25 +- .../agent-spine-demo/src/invariant.ts | 25 +- packages/examples/cli-demo/src/invariant.ts | 25 +- .../examples/jsonrpc-demo/src/invariant.ts | 20 +- packages/examples/stdio-demo/src/invariant.ts | 25 +- packages/fs/fs-local/src/invariant.ts | 28 +- packages/fs/fs-policy/src/invariant.ts | 27 +- packages/fs/fs/src/invariant.ts | 29 +- packages/fs/fs/tests/invariant.spec.ts | 44 +++ packages/fs/tool-fs-search/src/invariant.ts | 30 +- packages/fs/tool-fs/src/invariant.ts | 30 +- .../guard/repeat-tool-guard/src/invariant.ts | 26 +- packages/hooks/hook-protocol/src/invariant.ts | 103 ++++-- .../hook-protocol/tests/invariant.spec.ts | 86 +++++ packages/hooks/hooks-claude/src/invariant.ts | 41 +-- .../hooks-claude/tests/invariant.spec.ts | 26 -- packages/hooks/hooks-codex/src/invariant.ts | 39 +-- .../hooks/hooks-codex/tests/invariant.spec.ts | 26 -- packages/llm/llm-deepseek/src/invariant.ts | 28 +- packages/llm/llm-pi-ai/src/invariant.ts | 28 +- packages/llm/llm/src/invariant.ts | 91 +++++- packages/llm/llm/tests/invariant.spec.ts | 86 +++++ packages/llm/llm/tests/service.spec.ts | 4 +- packages/llm/token-meter/src/invariant.ts | 29 +- packages/mcp/mcp-client/src/invariant.ts | 29 +- .../sandbox/sandbox-local/src/invariant.ts | 28 +- packages/sandbox/sandbox/src/invariant.ts | 22 +- .../sandbox/sandbox/tests/invariant.spec.ts | 43 --- packages/sdk/create-sdk/src/invariant.ts | 33 +- packages/sdk/helper/src/invariant.ts | 27 +- packages/sdk/scripts/src/invariant.ts | 29 +- packages/sdk/telemetry/src/invariant.ts | 26 +- .../src/invariant.ts | 28 +- .../src/invariant.ts | 28 +- .../session-persistence/src/invariant.ts | 19 +- .../session-query/src/invariant.ts | 31 +- packages/skill/skill-local/src/invariant.ts | 28 +- packages/skill/skill/src/invariant.ts | 28 +- packages/skill/tool-skill/src/invariant.ts | 30 +- packages/spill/spill-local/src/invariant.ts | 28 +- packages/spill/spill-policy/src/invariant.ts | 32 +- packages/spill/spill/src/invariant.ts | 22 +- .../subagent/subagent-acp/src/invariant.ts | 28 +- .../subagent/subagent-fork/src/invariant.ts | 28 +- .../subagent-inprocess/src/invariant.ts | 22 +- .../subagent/subagent-spawn/src/invariant.ts | 28 +- .../subagent-subprocess/src/invariant.ts | 35 +- packages/subagent/subagent/src/invariant.ts | 89 +++++- .../subagent/subagent/tests/invariant.spec.ts | 82 +++++ .../subagent/tool-subagent/src/invariant.ts | 30 +- .../support/acp-snapshot/src/invariant.ts | 32 +- .../agent-loop-testkit/src/invariant.ts | 23 +- packages/support/invariants/README.md | 46 +-- packages/support/invariants/src/index.ts | 298 +----------------- packages/support/invariants/src/invariant.ts | 29 +- .../support/invariants/tests/service.spec.ts | 287 ----------------- packages/support/llm-replay/src/invariant.ts | 31 +- .../support/loader-smoke/src/invariant.ts | 30 +- packages/tasks/tasks/src/invariant.ts | 56 +++- packages/tasks/tasks/tests/invariant.spec.ts | 87 +++++ packages/tasks/tool-tasks/src/invariant.ts | 30 +- .../timeout/timeout-policy/src/invariant.ts | 28 +- packages/todo/tool-todo/src/invariant.ts | 47 ++- .../todo/tool-todo/tests/invariant.spec.ts | 53 ++++ packages/ui/acp/src/invariant.ts | 35 +- packages/ui/app-boot/src/invariant.ts | 26 +- packages/ui/jsonrpc/src/invariant.ts | 28 +- packages/ui/permission/src/invariant.ts | 35 +- .../ui/permission/tests/invariant.spec.ts | 42 +++ packages/ui/stdio/src/invariant.ts | 30 +- packages/ui/tool-ask-user/src/invariant.ts | 29 +- packages/ui/tui/src/invariant.ts | 31 +- packages/ui/user-approval/src/invariant.ts | 89 +++++- .../ui/user-approval/tests/invariant.spec.ts | 72 +++++ packages/ui/user-interaction/src/invariant.ts | 28 +- packages/util/brand/src/invariant.ts | 20 +- packages/util/home/src/invariant.ts | 25 +- packages/util/paths/src/invariant.ts | 26 +- packages/util/retention/src/invariant.ts | 31 +- packages/util/timeout/src/invariant.ts | 26 +- packages/web/tool-web/src/invariant.ts | 30 +- packages/web/web-fetch-local/src/invariant.ts | 28 +- .../web/web-search-deepseek/src/invariant.ts | 25 +- packages/web/web-search-exa/src/invariant.ts | 28 +- .../web-search-perplexity/src/invariant.ts | 25 +- packages/web/web/src/invariant.ts | 28 +- .../workflow/tool-workflow/src/invariant.ts | 30 +- .../workflow-workerthread/src/invariant.ts | 28 +- packages/workflow/workflow/src/invariant.ts | 126 +++++++- .../workflow/workflow/tests/invariant.spec.ts | 108 +++++++ .../workflow/workflow/tests/workflow.spec.ts | 10 + scripts/package-invariants.spec.ts | 83 ++--- scripts/package-invariants.ts | 70 ++-- scripts/test-invariants.spec.ts | 14 + scripts/test-invariants.ts | 14 + tsconfig.base.json | 30 ++ 124 files changed, 2923 insertions(+), 2334 deletions(-) create mode 100644 packages/bash/bash/tests/invariant.spec.ts create mode 100644 packages/compact/compact/tests/invariant.spec.ts create mode 100644 packages/context/time-context/tests/invariant.spec.ts create mode 100644 packages/core/system-prompt/tests/invariant.spec.ts create mode 100644 packages/core/tools/tests/invariant.spec.ts create mode 100644 packages/fs/fs/tests/invariant.spec.ts create mode 100644 packages/hooks/hook-protocol/tests/invariant.spec.ts delete mode 100644 packages/hooks/hooks-claude/tests/invariant.spec.ts delete mode 100644 packages/hooks/hooks-codex/tests/invariant.spec.ts create mode 100644 packages/llm/llm/tests/invariant.spec.ts delete mode 100644 packages/sandbox/sandbox/tests/invariant.spec.ts create mode 100644 packages/subagent/subagent/tests/invariant.spec.ts create mode 100644 packages/tasks/tasks/tests/invariant.spec.ts create mode 100644 packages/todo/tool-todo/tests/invariant.spec.ts create mode 100644 packages/ui/permission/tests/invariant.spec.ts create mode 100644 packages/ui/user-approval/tests/invariant.spec.ts create mode 100644 packages/workflow/workflow/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 48407e2272..a02e27ef7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 65986fc0b3aab695d8512d9e221052e1db83445f -2026-07-19-package-invariant-runtime-contracts.zh.md: 0ae0bd305692ee71359c5500e477dee22575a9ef +2026-07-19-package-invariant-runtime-contracts.md: 4e15842d1228d82621f91caa18cbe02fd5407dd8 +2026-07-19-package-invariant-runtime-contracts.zh.md: 88b1eb1a79d88865515617d6737fbded10a13b6e diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 65986fc0b3..4e15842d12 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -1,4 +1,4 @@ -# Agent Note: Executable package invariant contracts +# Agent Note: Meaningful package invariant contracts Status: implemented @@ -6,60 +6,70 @@ English | [中文](2026-07-19-package-invariant-runtime-contracts.zh.md) ## Problem -The package-owned invariant seam made registration and publication exhaustive, but its generated baseline treated package-name ownership as sufficient. An empty installer could satisfy the repository gate while observing no runtime state and rejecting no invalid state. That made the exhaustive count a wiring claim rather than protection for the package contract. +The package-owned invariant seam made publication and registration exhaustive, but its first generated baseline accepted empty installers. A follow-up then replaced those empties with generic assertions about plugin names, injections, effects, service methods, and fixed pure-library examples. Those assertions made every companion executable without making the system safer: TypeScript, Cordis startup, package tests, and module-load tests already enforce those shapes, while the invariant service should detect impossible runtime state. -Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. +A useful runtime invariant relates observations over time or across a mutable data structure. Examples include a terminal event without its start, an LLM delta for a block that is not open, or a durable result whose identity differs from its request. Merely confirming that a declared method exists, that a plugin has its expected name, or that a constant example still returns a known value is not such a relation. -Vitest mounts each package test's owning companion globally, and one exhaustive topology mounts every companion. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. +Some packages genuinely own no continuously observable relation. Pure utilities, composition-only packages, thin adapters, binaries, and test-support packages may have important contracts, but those contracts are better enforced by types, load checks, focused unit tests, or integration tests. Requiring a synthetic runtime assertion for those packages would optimize for satisfying a gate instead of detecting corruption. ## Decision -### Every companion executes a package contract +### Registration is exhaustive; assertions must be meaningful -Every workspace package keeps its separately published `./invariant` companion and exact npm-name registration, but the installer must execute at least one package-specific check through the bound `fail(message)` reporter. The ownership-baseline generator and its root script entry are removed; generated markers, empty installers, and installers that never reference the reporter are repository errors. +Every workspace package publishes a separately built `./invariant` companion and registers its exact npm package name. A companion does one of two things: -The implemented contracts use four forms: +- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; or +- uses an empty installer whose declaration has an owner-specific `No runtime invariant:` comment explaining why the package has no plausible runtime relation to observe. -| Owner shape | Runtime contract | +The empty form is an explicit architectural conclusion, not a generated placeholder. A future package change that introduces mutable state or an event protocol must replace the explanation with the corresponding check. + +The central `dsh-invariants` service owns only configuration, registration uniqueness, child-fiber lifecycle, rollback, disposal, and package-attributed failure. It exposes no generic plugin-shape, service-shape, or startup-assertion helpers and imports no product package. + +### Implemented checks + +The current 91-package workspace has 18 executable companions and 73 justified empty companions. + +| Owner | Runtime relationship | |---|---| -| Stateful session, agent, scope, and agent-loop owners | Validate event ordering, enclosure, status transitions, scoped subjects, and reconstructable model requests. | -| Cordis plugin owners | Validate the plugin's own declared runtime name, required injections, owned effects, provided services, and package-specific all-or-none or config-dependent relations. | -| Cordis service seams | Validate the structural method and descriptor surface of current and future implementations. | -| Pure libraries, bins, and support packages | Validate stable parser mapping, protocol precedence, retention and timeout algebra, path resolution, normalization, environment scrubbing, or deliberately empty runtime entrypoints. | +| `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. | +| `dsh-agent` | Non-repeating agent status and terminal disposal transitions. | +| `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | +| `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | +| `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | +| `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | +| `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | +| `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | +| `dsh-hook-protocol` | Hook invocation/result correlation, dialect, identity, and duration constraints. | +| `dsh-bash` | Durable sandbox-mode events use the closed sandbox-mode vocabulary. | +| `dsh-fs` | Filesystem decision/observation events carry usable target and version identities. | +| `dsh-subagent` | Provider add/remove and child start/end events preserve identity and pairing. | +| `dsh-permission` | Durable permission decisions name a preset in the active permission table. | +| `dsh-user-approval` | Approval asked/decided records pair by call and use valid outcomes and policies. | +| `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | +| `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | +| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | +| `dsh-time-context` | Plugin-attributed clock readings agree across turn, step, elapsed baseline, rendered time, and event time. | -At implementation time this covers all 91 workspace packages: four stateful companions, 62 plugin-fiber companions, eight service-shape companions, and 17 pure/bin/support companions. +Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. -### Product-independent observers +### Repository gate and tests -`observePluginInvariant` checks existing fibers immediately and future active fibers through a callback/name index behind one root-shared Cordis lifecycle listener pair. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter. The gate deliberately does not infer semantic quality from method names or helper calls. -`observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. - -`assertInvariant` handles package algebra. Pure-package companions return an asynchronous installer promise and dynamically import their owner during child startup. The service joins that promise for atomic rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. - -### Gate and test execution - -`verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. - -The Vitest setup host mounts `InvariantService` with `{ enabled: true }` before an ordinary Cordis root's first plugin and adds the current test package's companion. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. One exhaustive topology mounts all 91 companions once to prove runtime registration and coverage; focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. - -Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered -- **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. -- **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. -- **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. -- **Import every owner entrypoint statically from its companion.** Rejected because owning and exhaustive test hosts would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. -- **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. -- **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. +- **Keep generated empty companions.** Rejected because an unexplained placeholder can survive after a package gains a meaningful runtime relation. +- **Require an assertion from every package.** Rejected because method-presence, plugin-shape, and fixed-example assertions duplicate stronger type, load, and unit-test contracts without checking runtime consistency. +- **Keep generic shape helpers in the service.** Rejected because they blur compile-time API validation with runtime invariants and encourage centrally defined product assumptions. +- **Move the product checks into the service.** Rejected because product vocabulary, dependencies, tests, and change ownership belong with the package that emits the data. +- **Register companions implicitly from root entrypoints.** Rejected because composition order and optional service presence would create hidden effects. ## Consequences -- Every package contributes an executable check; adding a package without one fails the top-level gate. -- The invariant service remains product-independent while providing reusable lifecycle and shape observers. -- Ordinary unit, snapshot, and e2e roots run with global invariant enablement and the test package's companion; one exhaustive topology registers every companion. -- Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. -- Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. -- Relevant companion work runs during package tests and selected deployments, trading bounded startup cost for immediate package-attributed failures. -- The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. +- Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state. +- Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed. +- Type declarations, Cordis loadability, plugin metadata, service method surfaces, and pure algebra remain covered by their owning compile, load, unit, or integration gates. +- Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape. +- The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 0ae0bd3056..88b1eb1a79 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -1,65 +1,75 @@ -# Agent Note: 可执行的包不变式契约 +# Agent Note:有意义的包不变量契约 -Status: implemented +状态:已实施 [English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 ## 问题 -包拥有的不变式接缝让注册与发布覆盖完整,但生成的基线把包名所有权视为充分条件。空 installer 可以通过仓库门禁,却不观察任何运行时状态,也不拒绝任何无效状态。这样一来,完整计数只能证明接线存在,不能保护包契约。 +包自有不变量接缝让发布和注册实现了全覆盖,但最初的生成基线允许空安装器。后续方案又用针对插件名称、注入、effect、服务方法和固定纯函数示例的通用断言替代这些空实现。这些断言虽然让每个 companion 都能执行,却没有提高系统安全性:TypeScript、Cordis 启动、包测试和模块加载测试已经约束这些形状,而不变量服务应当发现不可能出现的运行时状态。 -不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 +有用的运行时不变量会关联时间上的多个观测,或关联可变数据结构中的多个部分。例如:终止事件没有对应的开始事件、LLM delta 指向未打开的 block,或持久化结果的身份与请求不同。仅确认声明的方法存在、插件名称符合预期,或常量示例仍返回已知值,都不属于这种关系。 -Vitest 会为每个包测试全局挂载其所有者伴随插件,并由一个完整拓扑挂载全部伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 +有些包确实没有可持续观测的关系。纯工具、仅负责组合的包、薄适配器、可执行入口和测试支持包可能仍有重要契约,但类型检查、加载检查、聚焦单元测试或集成测试更适合执行这些契约。强迫这些包添加合成运行时断言,只会让实现围绕通过门禁优化,而不是检测损坏。 ## 决策 -### 每个伴随插件都执行包契约 +### 注册必须全覆盖;断言必须有意义 -每个工作区包保留独立发布的 `./invariant` 伴随插件和准确 npm 包名注册,但 installer 必须通过绑定的 `fail(message)` 报告器执行至少一个包专属检查。删除所有权基线生成器及其根脚本入口;生成标记、空 installer 和从不引用报告器的 installer 都属于仓库错误。 +每个 workspace 包都发布单独构建的 `./invariant` companion,并用完整 npm 包名注册。companion 只能采用以下两种形式之一: -实现后的契约采用四种形态: +- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;或 +- 使用空安装器,并在其声明前写一条该包专属的 `No runtime invariant:` 注释,说明为什么该包没有合理的运行时关系可供观测。 -| 所有者形态 | 运行时契约 | +空形式是明确的架构结论,不是生成占位符。如果后续包变更引入可变状态或事件协议,就必须用相应检查替换该说明。 + +中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、释放和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 + +### 已实施的检查 + +当前 91 个包的 workspace 包含 18 个可执行 companion 和 73 个有理由的空 companion。 + +| 所有者 | 运行时关系 | |---|---| -| 有状态的 session、agent、scope 与 agent-loop 所有者 | 验证事件顺序、包围关系、状态转换、作用域主体和可重建的模型请求。 | -| Cordis 插件所有者 | 验证插件自身声明的运行时名称、必要注入、拥有的 effect、提供的服务,以及包专属的全有或全无关系或配置依赖关系。 | -| Cordis 服务接缝 | 验证当前和未来实现的结构化方法与描述字段表面。 | -| 纯库、bin 与支持包 | 验证稳定的解析映射、协议优先级、保留与超时代数、路径解析、规范化、环境清理或刻意为空的运行时入口。 | +| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 | +| `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 | +| `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | +| `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | +| `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | +| `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | +| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | +| `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | +| `dsh-hook-protocol` | hook invocation/result 的关联、dialect、身份和 duration 约束。 | +| `dsh-bash` | 持久化 sandbox-mode 事件必须使用封闭的 sandbox-mode 词表。 | +| `dsh-fs` | 文件系统决策/观测事件必须携带可用的 target 和 version 身份。 | +| `dsh-subagent` | provider add/remove 和 child start/end 事件必须保持身份与配对。 | +| `dsh-permission` | 持久化 permission 决策必须引用当前 permission 表中的 preset。 | +| `dsh-user-approval` | approval asked/decided 记录按 call 配对,并使用有效 outcome 和 policy。 | +| `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | +| `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | +| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 在 turn、step、elapsed baseline、渲染时间和事件时间之间保持一致。 | -实现时覆盖全部 91 个工作区包:四个有状态伴随插件、62 个插件 fiber 伴随插件、八个服务形状伴随插件和 17 个纯库、bin 或支持包伴随插件。 +基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 -### 与产品无关的观察器 +### 仓库门禁与测试 -`observePluginInvariant` 会立即检查已有 fiber,并通过根上下文共享的一对 Cordis 生命周期监听器背后的 callback/名称索引检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器。门禁不会通过方法名或 helper 调用推断语义质量。 -`observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 - -`assertInvariant` 处理包代数。纯包伴随插件返回异步 installer promise,并在子 fiber 启动期间动态导入所有者。服务会等待该 promise 以保证原子回滚,同时允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 - -### 门禁与测试执行 - -`verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 - -Vitest setup host 会在普通 Cordis 根上下文启动第一个插件前,以 `{ enabled: true }` 挂载 `InvariantService`,并添加当前测试包的伴随插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。一个完整拓扑会一次挂载全部 91 个伴随插件,以证明运行时注册与覆盖率;选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 - -辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 -- **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 -- **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 -- **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 -- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为所有者测试 host 与完整测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 -- **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 -- **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 +- **保留生成的空 companion。** 拒绝,因为包获得有意义的运行时关系后,没有解释的占位符仍可能继续存在。 +- **要求每个包都执行断言。** 拒绝,因为方法存在性、插件形状和固定示例断言会重复更强的类型、加载和单元测试契约,却没有检查运行时一致性。 +- **在服务中保留通用形状 helper。** 拒绝,因为这会混淆编译期 API 验证和运行时不变量,并鼓励在中央定义产品假设。 +- **把产品检查移入服务。** 拒绝,因为产品词汇、依赖、测试和变更所有权应归属于产生这些数据的包。 +- **从根入口隐式注册 companion。** 拒绝,因为组合顺序和可选服务存在性会产生隐藏 effect。 ## 后果 -- 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 -- 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 -- 普通单元、snapshot 与 e2e 根上下文均全局启用不变式并注册当前测试包的伴随插件;一个完整拓扑注册全部伴随插件。 -- 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 -- 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 -- 包测试和被选部署会执行相关伴随工作,以有界启动成本换取即时且带包归属的失败。 -- 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 +- 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态。 +- 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。 +- 类型声明、Cordis 可加载性、插件 metadata、服务方法形状和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 +- 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。 +- 原有 selection、blocklist 优先级、重复所有权、回滚、释放和 HMR 服务契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 9ab8642f4b..c37979ae66 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 552b088c1cafc2fa762487f57fa1d4ad64390f7e -2026-07-19-package-owned-invariant-service.zh.md: 84bdc6a6a3baefe95713f8ce5b8a4e2af49b64eb +2026-07-19-package-owned-invariant-service.md: bd167f8da1e855b703b75a60e2dbd8959f5bf9b3 +2026-07-19-package-owned-invariant-service.zh.md: 6bd4f30123b7c7ba39266d5d31dd4b378604fd5a diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 552b088c1c..bd167f8da1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name and installs an executable package-specific contract. Generated ownership-only installers are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A companion checks a meaningful event or mutable-data relationship when its owner has one; otherwise it carries an owner-specific explanation for its empty installer. Generated ownership placeholders and synthetic API-shape assertions are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -55,7 +55,7 @@ Registration setup is transactional. If an installer fails after registering lis The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. -### Stateful companions and exhaustive ownership +### Initial stateful companions and exhaustive ownership | Companion entry | Registration name | Owned checks | |---|---|---| @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Other owners check their plugin fibers and effects, structural service implementations, or stable pure-library algebra. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for fourteen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, empty or reporter-free installers, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. +`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. ### Scoped-event semantic map @@ -80,7 +80,7 @@ Workspace constraints recognize the separate invariant bundle, and package expor ## Testing -Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owner tests keep each invariant's positive and negative behavior beside its source. +Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owners with executable checks keep positive and negative behavior beside the companion source. Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. @@ -96,10 +96,10 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the publication, dependency, and runtime-check cost of an executable invariant companion. +- Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. -- One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. +- One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. - Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 84bdc6a6a3..6bd4f30123 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名,并安装可执行的包专属契约。后续的[运行时契约 Agent Note](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的仅声明所有权 installer。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名。如果所有者具备有意义的事件或可变数据关系,companion 就检查该关系;否则空 installer 必须携带该所有者专属的说明。后续的[运行时契约 Agent Note](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的所有权占位符和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -55,7 +55,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 -### 有状态伴随插件与完整所有权 +### 首批有状态伴随插件与完整所有权 | 伴随入口 | 注册名 | 所属检查 | |---|---|---| @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他所有者检查自己的插件 fiber 与 effect、结构化服务实现或稳定的纯库代数。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者提供了首批有状态检查。后续运行时契约决策为另外十四个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、空 installer、不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 ### Scoped event 语义映射 @@ -80,7 +80,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 测试 -服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。所有者测试把各不变式的正向与负向行为保留在其源码旁边。 +服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。具备可执行检查的所有者会把正向与负向行为保留在 companion 源码旁边。 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 @@ -96,10 +96,10 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担可执行不变式伴随插件带来的发布、依赖与运行时检查成本。 +- 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 -- 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 +- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 - 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1219088e70..16ee490395 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns executable invariants.** Publish `./invariant`, register its manifest name, and enforce a runtime contract with the bound reporter; generated, empty, and reporter-free installers fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). +- **Every package owns an explicit invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relationships; when none exists, keep an empty installer with a package-specific `No runtime invariant:` explanation instead of inventing an API-shape assertion. Generated companions, unexplained empties, and non-empty installers that ignore the reporter fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 397bf0d6dc..3cc7bd62e2 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-local`. @module @deepseek-ai/dsh-bash-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-local`. + * @module @deepseek-ai/dsh-bash-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' /** Cordis companion plugin name. */ export const name = 'bash-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalBashExecutor', - effects: [ - 'ctx.provide("bash")', - 'local bash teardown', - ], - services: [ - 'bash', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index f758c90f6f..b79b626033 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-sandbox`. @module @deepseek-ai/dsh-bash-sandbox/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-sandbox`. + * @module @deepseek-ai/dsh-bash-sandbox/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' /** Cordis companion plugin name. */ export const name = 'bash-sandbox-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SandboxBashExecutor', - inject: [ - 'sandbox', - ], - effects: [ - 'ctx.provide("bash")', - ], - services: [ - 'bash', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 91f135b1bb..1229e19e4c 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,24 +1,30 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash`. @module @deepseek-ai/dsh-bash/invariant */ +/** Package-owned session-event invariants for the bash seam. @module @deepseek-ai/dsh-bash/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { SANDBOX_MODES } from './session-mode.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' /** Cordis companion plugin name. */ export const name = 'bash-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +/** Install validation for the durable sandbox-mode vocabulary. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'bash', value => serviceShapeViolation(value, { - methods: ['resolve', 'run', 'start'], - })) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'bash/sandbox-mode' && !SANDBOX_MODES.includes(event.data.mode)) { + fail(`bash/sandbox-mode carries unknown mode ${JSON.stringify(event.data.mode)}`) + } + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the bash invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/bash/bash/tests/invariant.spec.ts b/packages/bash/bash/tests/invariant.spec.ts new file mode 100644 index 0000000000..9d6209042f --- /dev/null +++ b/packages/bash/bash/tests/invariant.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as BashInvariant from '@deepseek-ai/dsh-bash/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(BashInvariant) + return ctx +} + +function modeEvent(mode: string): SessionEvent { + return { type: 'bash/sandbox-mode', seq: 0, time: 0, data: { mode } } as SessionEvent +} + +describe('bash invariants', () => { + it('accepts the closed sandbox vocabulary and ignores unrelated events', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, modeEvent('workspace-write')) }).not.toThrow() + expect(() => { ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: {}, + } as SessionEvent) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects an unknown durable sandbox mode', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, modeEvent('host-root')) }) + .toThrow(/unknown mode "host-root"/) + }) +}) diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index ca63f9ff77..0620f0cfa9 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-bash`. @module @deepseek-ai/dsh-tool-bash/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`. + * @module @deepseek-ai/dsh-tool-bash/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' /** Cordis companion plugin name. */ export const name = 'tool-bash-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-bash', - inject: [ - 'tools', - 'bash', - 'systemPrompt', - ], - effects: [ - 'ctx.provide("bashEnv")', - 'bashEnv.register()', - 'tools.register()', - ], - services: [ - 'bashEnv', - ], - }) -} +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * mutation/read; it publishes no independent snapshot that a companion could cross-check. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -37,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 63daf58ae9..3455104441 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -1,31 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime-worker`. + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-worker`. * @module @deepseek-ai/dsh-code-runtime-worker/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' /** Cordis companion plugin name. */ export const name = 'code-runtime-worker-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WorkerCodeRuntime', - effects: [ - 'ctx.provide("codeRuntime")', - 'worker code-runtime teardown', - ], - services: [ - 'codeRuntime', - ], - }) -} +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * worker protocol and built-worker tests cover it. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -34,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 1ee564c60b..9c4019699b 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime`. @module @deepseek-ai/dsh-code-runtime/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime`. + * @module @deepseek-ai/dsh-code-runtime/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' /** Cordis companion plugin name. */ export const name = 'code-runtime-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'codeRuntime', (value) => { - const violation = serviceShapeViolation(value, { - methods: ['run'], - stringProperties: ['language', 'isolation'], - }) - if (violation !== undefined) return violation - const service = value as { language: string; isolation: string } - return /^[a-z][a-z0-9-]*$/.test(service.language) && /^[a-z][a-z0-9-]*$/.test(service.isolation) - ? undefined - : 'code runtime language and isolation must be lowercase identifiers' - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 68bc809de7..56a32930c9 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import { InvariantError } from '@deepseek-ai/dsh-invariants' /** * Minimal concrete runtime: records requests, "executes" by invoking every @@ -86,24 +85,4 @@ describe('CodeRuntime service seam', () => { await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) }) - it.each([ - [{ language: 'typescript', isolation: 'worker' }, /must expose method "run"/], - [{ language: 'TypeScript', isolation: 'worker', run() {} }, /must be lowercase identifiers/], - ])('rejects an invalid runtime implementation through the package invariant', async (value, message) => { - const ctx = new Context() - const invalidRuntime = { - name: 'invalid-code-runtime', - apply(child: Context) { - child.provide('codeRuntime', value as unknown as CodeRuntime) - }, - } - let caught: unknown - try { - await ctx.plugin(invalidRuntime) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(InvariantError) - expect((caught as Error).message).toMatch(message) - }) }) diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index 3e034a2f58..172790d233 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -1,46 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact-basic`. @module @deepseek-ai/dsh-compact-basic/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-compact-basic`. + * @module @deepseek-ai/dsh-compact-basic/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' /** Cordis companion plugin name. */ export const name = 'compact-basic-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'BasicCompactService', - inject: [ - 'llm', - 'tokenMeter', - ], - effects: [ - 'ctx.provide("compact")', - ], - services: [ - 'compact', - ], - validate: (fiber, effectLabels) => { - const automaticEffects = [ - 'ctx.on("agent/post-step")', - 'ctx.on("agent/request-error")', - ] - const installed = automaticEffects.filter(label => effectLabels.has(label)).length - const automatic = (fiber.config as { auto?: boolean }).auto !== false - if (automatic && installed !== automaticEffects.length) { - return 'automatic compaction must install both pressure and overflow listeners' - } - if (!automatic && installed !== 0) { - return 'auto:false must install neither automatic compaction listener' - } - return undefined - }, - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -49,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 762289c9d9..0b118ac245 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -5,7 +5,7 @@ import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' -import type { CompactService, CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -199,28 +199,6 @@ describe('compact configuration and defaults', () => { } }) - it.each([ - [{ auto: true }, false, /must install both pressure and overflow listeners/], - [{ auto: false }, true, /must install neither automatic compaction listener/], - ])('rejects an inconsistent automatic-listener topology through the package invariant', async (config, installListener, message) => { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService) - const invalidCompact = { - name: 'BasicCompactService', - inject: ['llm', 'tokenMeter'], - apply(child: Context, _config: { auto?: boolean }) { - child.provide('compact', { - compactIfNeeded() {}, - compactRegion() {}, - } as unknown as CompactService) - if (installListener) { - child.effect(() => () => {}, 'ctx.on("agent/post-step")') - } - }, - } - await expect(ctx.plugin(invalidCompact, config)).rejects.toThrow(message) - }) }) describe('pressure measurement and retention', () => { diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 974f7a29ca..59fdc806b2 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,24 +1,106 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact`. @module @deepseek-ai/dsh-compact/invariant */ +/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' /** Cordis companion plugin name. */ export const name = 'compact-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'compact', value => serviceShapeViolation(value, { - methods: ['compactIfNeeded', 'compactRegion'], - })) +interface CompactionTrace { + turn: number + summarized: boolean } +type CompactionTransition = + | { kind: 'start'; turn: number } + | { kind: 'summary'; turn: number } + | { kind: 'end' } + +/** Validate one compaction event without advancing committed trace state. */ +function validateCompactionEvent( + open: CompactionTrace | undefined, + event: SessionEvent, + fail: InvariantFailure, +): CompactionTransition | undefined { + if (event.type === 'compact/start') { + if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`) + return { kind: 'start', turn: event.data.turn } + } + if (event.type === 'compact/summary') { + if (open === undefined) fail('compact/summary has no matching compact/start') + if (open.summarized) fail('compact/summary repeated within one compaction') + const seqs = event.data.shadowedSeqs + if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty') + if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) { + fail('compact/summary shadowedRange must match the first and last shadowedSeqs') + } + if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { + fail('compact/summary shadowedTokenCount must be a non-negative safe integer') + } + return { kind: 'summary', turn: open.turn } + } + if (event.type !== 'compact/end') return undefined + if (open === undefined) fail('compact/end has no matching compact/start') + if (event.data.turn !== open.turn) { + fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`) + } + if (event.data.error === undefined && !open.summarized) { + fail('successful compact/end requires one compact/summary') + } + return { kind: 'end' } +} + +/** Apply one committed compaction transition. */ +function applyCompactionTransition( + transition: CompactionTransition, +): CompactionTrace | undefined { + if (transition.kind === 'start') return { turn: transition.turn, summarized: false } + if (transition.kind === 'summary') return { turn: transition.turn, summarized: true } + return undefined +} + +/** Install compaction start/summary/end checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const staged = new WeakMap() + const seed = (session: Session): void => { + let open: CompactionTrace | undefined + for (const event of session.events) { + const transition = validateCompactionEvent(open, event, fail) + if (transition !== undefined) open = applyCompactionTransition(transition) + } + if (open !== undefined) traces.set(session, open) + } + const traceFor = (session: Session): CompactionTrace | undefined => traces.get(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) => { + if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/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) + const next = applyCompactionTransition(candidate.transition) + if (next === undefined) traces.delete(session) + else traces.set(session, next) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateCompactionEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the compact invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 559d46bdc9..1ff03bdfb1 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -38,7 +38,7 @@ class StubCompactService extends CompactService { const summaryEvent = session.append('compact/summary', { summary, shadowedRange: { start, end }, - shadowedSeqs: [], + shadowedSeqs: [start], shadowedTokenCount: 0, provider: 'mock', model: 'stub', @@ -50,7 +50,7 @@ class StubCompactService extends CompactService { endSeq: endEvent.seq, summary, shadowedRange: { start, end }, - shadowedSeqs: [], + shadowedSeqs: [start], shadowedTokenCount: 0, } } diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts new file mode 100644 index 0000000000..2f4d10ff71 --- /dev/null +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(CompactInvariant) + return ctx +} + +const summary = (overrides: Record = {}) => ({ + summary: [{ type: 'text' as const, text: 'short' }], + shadowedRange: { start: 2, end: 4 }, + shadowedSeqs: [2, 3, 4], + shadowedTokenCount: 12, + provider: 'mock', + model: 'mock', + ...overrides, +}) + +describe('compaction invariants', () => { + it('accepts successful and failed compaction lifecycles', async () => { + const ctx = await setup() + const success = ctx.sessions.create() + success.append('compact/start', { turn: 1 }) + success.append('compact/summary', summary()) + success.append('compact/end', { turn: 1 }) + + const failed = ctx.sessions.create() + failed.append('compact/start', { turn: 2 }) + failed.append('compact/end', { turn: 2, error: 'provider failed' }) + }) + + 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, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('compact/start', { turn: 3 }) + await ctx.plugin(InvariantService) + await ctx.plugin(CompactInvariant) + expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it.each([ + ['summary without start', (session: ReturnType) => { + session.append('compact/summary', summary()) + }, /no matching compact\/start/], + ['nested start', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/start', { turn: 2 }) + }, /still compacting/], + ['repeated summary', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary()) + session.append('compact/summary', summary()) + }, /repeated within one compaction/], + ['empty shadow set', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedSeqs: [] })) + }, /shadowedSeqs must be non-empty/], + ['wrong endpoints', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } })) + }, /shadowedRange must match/], + ['invalid token count', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedTokenCount: -1 })) + }, /non-negative safe integer/], + ['end without start', (session: ReturnType) => { + session.append('compact/end', { turn: 1, error: 'failed' }) + }, /no matching compact\/start/], + ['wrong end turn', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 2, error: 'failed' }) + }, /does not match/], + ['success without summary', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 1 }) + }, /requires one compact\/summary/], + ])('rejects %s', async (_name, action, message) => { + const ctx = await setup() + expect(() => { action(ctx.sessions.create()) }).toThrow(message) + }) +}) diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index f1497f4c4a..804d419627 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,30 +1,64 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-time-context`. @module @deepseek-ai/dsh-time-context/invariant */ +/** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' +const SOURCE_NAME = 'time-context' +const READING = new RegExp( + '^Time sampled while preparing turn (\\d+), step (\\d+): ' + + '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n' + + 'Elapsed since the preceding (model-visible message|step context): ' + + '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$', +) /** Cordis companion plugin name. */ export const name = 'time-context-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate one plugin-attributed time reading against its durable event timestamp. */ +function validateReading(event: SessionEvent<'context/message'>, fail: InvariantFailure): void { + const [block] = event.data.content + if (event.data.content.length !== 1 || block?.type !== 'text') { + fail('time-context messages must contain exactly one text block') + } + const match = READING.exec(block.text) + if (match === null) fail('time-context message does not match the durable reading format') + const turn = Number(match[1]) + const step = Number(match[2]) + if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { + fail('time-context turn and step must be positive safe integers') + } + const baseline = match[4] + if ((step === 1) !== (baseline === 'model-visible message')) { + fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) + } + const rendered = match[3] + /* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */ + if (rendered === undefined) fail('time-context reading omitted its rendered timestamp') + const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, '')) + if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) + || event.time < renderedTime || event.time - renderedTime >= 1_000) { + fail('time-context rendered timestamp must identify the durable event second') + } +} + +/** Install validation for plugin-attributed context readings. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'time-context', - inject: [ - 'agents', - ], - effects: [ - 'ctx.on("agent/pre-step")', - ], - }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type !== 'context/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== SOURCE_NAME) return + validateReading(event, fail) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the time-context invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts new file mode 100644 index 0000000000..eab422e95e --- /dev/null +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +const SECOND = Date.parse('2026-07-14T00:00:00Z') + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(TimeInvariant) + return ctx +} + +function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent { + return { + type: 'context/message', + seq: 0, + time, + data: { + content: (content ?? [{ type: 'text', text }]) as ContentBlock[], + source: { kind: 'plugin', plugin: 'time-context' }, + }, + } +} + +function reading( + turn = '1', + step = '1', + baseline = 'model-visible message', + timestamp = '2026-07-14T00:00:00+00:00[UTC]', +): string { + return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n` + + `Elapsed since the preceding ${baseline}: unavailable.` +} + +describe('time-context invariants', () => { + it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { + const ctx = await setup() + const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' + + 'Elapsed since the preceding step context: 4m 2s.' + expect(() => { ctx.emit('session/event', {} as Session, event(text)) }).not.toThrow() + }) + + it.each([ + ['not a reading', SECOND, undefined, /durable reading format/], + [reading('0'), SECOND, undefined, /positive safe integers/], + [reading('999999999999999999999'), SECOND, undefined, /positive safe integers/], + [reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/], + [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/], + [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/], + [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/], + [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /durable event second/], + [reading(), Number.NaN, undefined, /durable event second/], + [reading(), SECOND - 1, undefined, /durable event second/], + [reading(), SECOND + 1_000, undefined, /durable event second/], + ['ignored', SECOND, [], /exactly one text block/], + ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/], + ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], + ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { + const ctx = await setup() + expect(() => { + ctx.emit('session/event', {} as Session, event(text, time, content === undefined ? undefined : [...content])) + }).toThrow(message) + }) + + it('ignores context messages owned by another package', async () => { + const ctx = await setup() + const other = event('unrelated') as SessionEvent<'context/message'> + other.data.source = { kind: 'plugin', plugin: 'other' } + expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + other.data.source = { kind: 'user' } + expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { + ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + ctx.emit('tools/change') + }).not.toThrow() + }) +}) diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index 0258acff14..d9f56417b8 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workspace-context`. @module @deepseek-ai/dsh-workspace-context/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-workspace-context`. + * @module @deepseek-ai/dsh-workspace-context/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' /** Cordis companion plugin name. */ export const name = 'workspace-context-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'workspace-context', - effects: [ - 'ctx.on("session/event")', - 'ctx.on("agent/session-prefix")', - 'ctx.on("tools/post-execute")', - 'ctx.on("tools/result")', - ], - }) -} +/** + * No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata, + * while focused pipeline tests own its private pending/cache state transitions. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index 8d09b3bb9e..6fd73d0353 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-cordis`. @module @deepseek-ai/dsh-tool-cordis/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`. + * @module @deepseek-ai/dsh-tool-cordis/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' /** Cordis companion plugin name. */ export const name = 'tool-cordis-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-cordis', - inject: [ - 'tools', - ], - effects: [ - 'ctx.plugin()', - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 36052bb90a..e199cc98b4 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,31 +1,50 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-system-prompt`. @module @deepseek-ai/dsh-system-prompt/invariant */ +/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { PromptAssembly } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' +const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** Cordis companion plugin name. */ export const name = 'system-prompt-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate the authoritative assembly returned by the waterfall. */ +function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): void { + const sectionNames = new Set() + for (const section of assembly.sections) { + if (section.name.length === 0) fail('assembled section names must be non-empty') + if (sectionNames.has(section.name)) fail(`assembled section name ${JSON.stringify(section.name)} is duplicated`) + sectionNames.add(section.name) + if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`) + } + + for (const tool of assembly.tools) { + if (tool.name.length === 0) fail('assembled tool names must be non-empty') + } + + for (const [name, value] of Object.entries(assembly.variables)) { + if (!VARIABLE_NAME.test(name)) fail(`assembled variable name ${JSON.stringify(name)} is invalid`) + if (value !== undefined && typeof value !== 'string') { + fail(`assembled variable ${JSON.stringify(name)} must be a string or undefined`) + } + } +} + +/** Install validation around the authoritative assembly waterfall result. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SystemPrompt', - effects: [ - 'ctx.provide("systemPrompt")', - 'systemPrompt.section()', - ], - services: [ - 'systemPrompt', - ], - }) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const assembled = await next() + validateAssembly(assembled, fail) + return assembled + }, { global: true, prepend: true }) } /** - * Register this package's invariant companion. + * Register the system-prompt invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/core/system-prompt/tests/invariant.spec.ts b/packages/core/system-prompt/tests/invariant.spec.ts new file mode 100644 index 0000000000..ce03af0b9f --- /dev/null +++ b/packages/core/system-prompt/tests/invariant.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(SystemPromptInvariant) + return ctx +} + +const valid = (): PromptAssembly => ({ + sections: [{ name: 'identity', text: 'prompt' }], + tools: [{ name: 'echo', description: 'Echo', parameters: {} }], + variables: { cwd: '/repo', optional: undefined }, +}) + +async function assemble(ctx: Context, result: PromptAssembly): Promise { + return ctx.waterfall( + ctx as never, 'system-prompt/assemble', valid(), {}, + () => Promise.resolve(result), + ) +} + +describe('system-prompt invariants', () => { + it('accepts a well-formed authoritative assembly', async () => { + const ctx = await setup() + await expect(assemble(ctx, valid())).resolves.toEqual(valid()) + }) + + it.each([ + [{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/], + [{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/], + [{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/], + [{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/], + [{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/], + [{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/], + ])('rejects malformed authoritative assembly %#', async (assembly, message) => { + const ctx = await setup() + await expect(assemble(ctx, assembly)).rejects.toThrow(message) + }) +}) diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index c5b53e67fa..2f5f27a281 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,34 +1,67 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tools`. @module @deepseek-ai/dsh-tools/invariant */ +/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ToolExecution, ToolExecutionResult } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tools' /** Cordis companion plugin name. */ export const name = 'tools-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +type ToolStage = 'pre' | 'execute' | 'post' + +/** Validate the immutable final execution/result snapshot. */ +function validateResult( + exec: Readonly, + result: Readonly, + fail: InvariantFailure, +): void { + if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication') + if (!Object.isFrozen(result) || !Object.isFrozen(result.content)) { + fail('tools/result outcome and content must be frozen before publication') + } + if (exec.name.length === 0 || String(exec.callId).length === 0) { + fail('tools/result execution must carry non-empty name and callId') + } +} + +/** Install monotonic pipeline and final-snapshot checks. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ToolRegistry', - inject: [ - 'systemPrompt', - ], - effects: [ - 'ctx.provide("tools")', - 'systemPrompt.tools()', - ], - services: [ - 'tools', - ], - }) + const stages = new WeakMap() + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'tools/pre-execute') { + const exec = args[0] as ToolExecution + if (stages.has(exec)) fail('tools/pre-execute repeated for one execution') + stages.set(exec, 'pre') + return + } + if (eventName === 'tools/execute') { + const exec = args[0] as ToolExecution + if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute') + stages.set(exec, 'execute') + return + } + if (eventName === 'tools/post-execute') { + const exec = args[0] as ToolExecution + const previous = stages.get(exec) + if (previous !== 'pre' && previous !== 'execute') { + fail('tools/post-execute must follow tools/pre-execute or tools/execute') + } + stages.set(exec, 'post') + return + } + if (eventName !== 'tools/result') return + const [exec, result] = args as [Readonly, Readonly] + validateResult(exec, result, fail) + stages.delete(exec) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the tools invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts new file mode 100644 index 0000000000..3db0e961ce --- /dev/null +++ b/packages/core/tools/tests/invariant.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(ToolsInvariant) + return ctx +} + +const execution = (overrides: Partial = {}): ToolExecution => ({ + token: Symbol('tool') as ToolExecutionToken, + callId: CallId('call-1'), + name: 'echo', + arguments: Object.freeze({ text: 'hi' }), + ...overrides, +}) + +const outcome = (): ToolExecutionResult => Object.freeze({ + content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never, + isError: false, +}) + +function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void { + ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result) +} + +async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise { + if (name === 'tools/pre-execute') { + await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const })) + } else { + await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome())) + } +} + +describe('tool-pipeline invariants', () => { + it('accepts dispatch and denial stage orders with frozen results', async () => { + const ctx = await setup() + const dispatched = execution() + await stage(ctx, 'tools/pre-execute', dispatched) + await stage(ctx, 'tools/execute', dispatched) + await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const })) + Object.freeze(dispatched) + emitResult(ctx, dispatched, outcome()) + + const denied = execution({ callId: CallId('call-2') }) + await stage(ctx, 'tools/pre-execute', denied) + await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const })) + Object.freeze(denied) + emitResult(ctx, denied, outcome()) + ctx.emit('tools/change') + }) + + it('rejects repeated and out-of-order pipeline stages', async () => { + const ctx = await setup() + const exec = execution() + await stage(ctx, 'tools/pre-execute', exec) + await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/) + + const noPre = execution({ callId: CallId('call-2') }) + await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/) + expect(() => ctx.waterfall( + ctx as never, 'tools/post-execute', noPre, outcome(), + () => Promise.resolve({ kind: 'accept' as const }), + )).toThrow(/must follow tools\/pre-execute or tools\/execute/) + }) + + it('rejects mutable or anonymous final snapshots', async () => { + const ctx = await setup() + expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/) + + const exec = Object.freeze(execution()) + expect(() => { emitResult(ctx, exec, { content: [], isError: false }) }) + .toThrow(/outcome and content must be frozen/) + + const anonymous = Object.freeze(execution({ name: '' })) + expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/) + }) +}) diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index b8801dd71e..95b57b57e1 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp-demo`. @module @deepseek-ai/dsh-acp-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp-demo`. + * @module @deepseek-ai/dsh-acp-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' /** Cordis companion plugin name. */ export const name = 'acp-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'acp-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 75ab162567..fada985329 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-agent-spine-demo`. @module @deepseek-ai/dsh-agent-spine-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-spine-demo`. + * @module @deepseek-ai/dsh-agent-spine-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' /** Cordis companion plugin name. */ export const name = 'agent-spine-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'agent-spine-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts index 016c0fd18f..8eb40e9268 100644 --- a/packages/examples/cli-demo/src/invariant.ts +++ b/packages/examples/cli-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-cli-demo`. @module @deepseek-ai/dsh-cli-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cli-demo`. + * @module @deepseek-ai/dsh-cli-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-cli-demo' /** Cordis companion plugin name. */ export const name = 'cli-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'cli-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index a9352d02f8..dd093a5418 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -1,22 +1,24 @@ -/** Package-owned runtime contract for @deepseek-ai/dsh-jsonrpc-demo. @module @deepseek-ai/dsh-jsonrpc-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc-demo`. + * @module @deepseek-ai/dsh-jsonrpc-demo/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' /** Cordis companion plugin name. */ export const name = 'jsonrpc-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 8e24a3b94b..125d01347c 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio-demo`. @module @deepseek-ai/dsh-stdio-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-stdio-demo`. + * @module @deepseek-ai/dsh-stdio-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' /** Cordis companion plugin name. */ export const name = 'stdio-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'stdio-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 6469bffae6..3e38550065 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-local`. @module @deepseek-ai/dsh-fs-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-fs-local`. + * @module @deepseek-ai/dsh-fs-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' /** Cordis companion plugin name. */ export const name = 'fs-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalFileSystem', - effects: [ - 'ctx.provide("fs")', - ], - services: [ - 'fs', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index de77ac303d..369fa5ea84 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -1,26 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-policy`. @module @deepseek-ai/dsh-fs-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-fs-policy`. + * @module @deepseek-ai/dsh-fs-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' /** Cordis companion plugin name. */ export const name = 'fs-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'fs-policy', - effects: [ - 'ctx.on("fs/write-intent")', - 'ctx.on("fs/edit-intent")', - 'ctx.on("fs/observed")', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -29,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 586f962247..429c1cec41 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,24 +1,37 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs`. @module @deepseek-ai/dsh-fs/invariant */ +/** Package-owned filesystem event-data invariants. @module @deepseek-ai/dsh-fs/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { FsTarget, FsVersion } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-fs' /** Cordis companion plugin name. */ export const name = 'fs-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +/** Assert that an event carries a usable opaque target identity. */ +function validateTarget(target: FsTarget, fail: (message: string) => never): void { + if (target.targetKey.length === 0) fail('filesystem event targetKey must be non-empty') + if (target.displayPath.length === 0) fail('filesystem event displayPath must be non-empty') +} + +/** Install checks over the filesystem decision and observation event stream. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'fs', value => serviceShapeViolation(value, { - methods: ['resolve', 'stat', 'lstat', 'readText', 'streamText', 'listDir', 'writeText', 'editText'], - })) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'fs/write-intent' + && eventName !== 'fs/edit-intent' + && eventName !== 'fs/observed') return + validateTarget(args[0] as FsTarget, fail) + if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) { + fail('fs/observed version must be non-empty') + } + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the filesystem invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/fs/fs/tests/invariant.spec.ts b/packages/fs/fs/tests/invariant.spec.ts new file mode 100644 index 0000000000..c160e3d238 --- /dev/null +++ b/packages/fs/fs/tests/invariant.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget } from '@deepseek-ai/dsh-fs' +import * as FsInvariant from '@deepseek-ai/dsh-fs/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(FsInvariant) + return ctx +} + +const target = (key = 'file:1', displayPath = 'file.txt'): FsTarget => ({ + targetKey: FsTargetKey(key), + displayPath, +}) + +describe('filesystem invariants', () => { + it('accepts decision and observation events with usable identities', async () => { + const ctx = await setup() + await expect(ctx.waterfall( + ctx as never, 'fs/write-intent', target(), undefined, + () => Promise.resolve(undefined), + )).resolves.toBeUndefined() + await expect(ctx.waterfall( + ctx as never, 'fs/edit-intent', target(), undefined, + () => Promise.resolve(undefined), + )).resolves.toBeUndefined() + expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects empty target and version identities', async () => { + const ctx = await setup() + expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) }) + .toThrow(/targetKey must be non-empty/) + expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) }) + .toThrow(/displayPath must be non-empty/) + expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) }) + .toThrow(/version must be non-empty/) + }) +}) diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index 65da611560..f7f206896d 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs-search`. @module @deepseek-ai/dsh-tool-fs-search/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs-search`. + * @module @deepseek-ai/dsh-tool-fs-search/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' /** Cordis companion plugin name. */ export const name = 'tool-fs-search-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-fs-search', - inject: [ - 'tools', - 'systemPrompt', - 'bash', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index caa31a13b5..eaa2485c06 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs`. @module @deepseek-ai/dsh-tool-fs/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs`. + * @module @deepseek-ai/dsh-tool-fs/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' /** Cordis companion plugin name. */ export const name = 'tool-fs-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-fs', - inject: [ - 'tools', - 'fs', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 6bbdd1b539..5d8544b9aa 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -1,25 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-repeat-tool-guard`. @module @deepseek-ai/dsh-repeat-tool-guard/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-repeat-tool-guard`. + * @module @deepseek-ai/dsh-repeat-tool-guard/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' /** Cordis companion plugin name. */ export const name = 'repeat-tool-guard-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'repeat-tool-guard', - effects: [ - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/prompt-submit")', - ], - }) -} +/** + * No runtime invariant: the repeat chain is private to one post-execute listener and exposes no + * package-owned event or snapshot that an independent companion can observe. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -28,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 997dd75b30..73d6f08e3b 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,39 +1,98 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-hook-protocol. @module @deepseek-ai/dsh-hook-protocol/invariant */ +/** Package-owned hook provenance-stream invariants. @module @deepseek-ai/dsh-hook-protocol/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' /** Cordis companion plugin name. */ export const name = 'hook-protocol-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert blocking-exit decoding and restrictive merge precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ parseHookOutput }, { mergeHookOutputs }] = await Promise.all([ - import('./codec.ts'), - import('./merge.ts'), - ]) - const blocked = parseHookOutput(2, '', ' denied ') - assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', - 'exit 2 must decode as a block whose reason is trimmed stderr') - - const merged = mergeHookOutputs([ - { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, - { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, - ]) - assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', - 'deny must override allow and retain only the winning decision reason') +interface HookTransition { + key: string + delta: 1 | -1 } +/** Correlation key shared by an invoked/result pair. */ +function hookKey(data: { turn: number; point: string; handlerId: string }): string { + return `${data.turn}\0${data.point}\0${data.handlerId}` +} + +/** Validate one hook event against committed pending invocations. */ +function validateHookEvent( + pending: ReadonlyMap, + event: SessionEvent, + fail: InvariantFailure, +): HookTransition | undefined { + if (event.type === 'hook/invoked') { + if (event.data.point.length === 0 || event.data.handlerId.length === 0) { + fail('hook/invoked point and handlerId must be non-empty') + } + const dialect: string = event.data.dialect + if (dialect !== 'claude' && dialect !== 'codex') { + fail(`hook/invoked carries unknown dialect ${JSON.stringify(dialect)}`) + } + return { key: hookKey(event.data), delta: 1 } + } + if (event.type !== 'hook/result') return undefined + const key = hookKey(event.data) + if ((pending.get(key) ?? 0) === 0) { + fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`) + } + if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) { + fail('hook/result durationMs must be a non-negative finite number') + } + return { key, delta: -1 } +} + +/** Apply one committed hook-pair transition. */ +function applyHookTransition(pending: Map, transition: HookTransition): void { + const next = (pending.get(transition.key) ?? 0) + transition.delta + if (next === 0) pending.delete(transition.key) + else pending.set(transition.key, next) +} + +/** Install hook invoked/result pairing checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap>() + const staged = new WeakMap() + const seed = (session: Session): Map => { + const pending = new Map() + traces.set(session, pending) + for (const event of session.events) { + const transition = validateHookEvent(pending, event, fail) + if (transition !== undefined) applyHookTransition(pending, transition) + } + return pending + } + const traceFor = (session: Session): Map => 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) => { + if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every hook provenance event */ + if (candidate === undefined || candidate.session !== session) return fail('hook event published without pre-commit validation') + staged.delete(event) + applyHookTransition(traceFor(session), candidate.transition) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateHookEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the hook-protocol 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)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts new file mode 100644 index 0000000000..dc7b1d38bb --- /dev/null +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(HookInvariant) + return ctx +} + +const invoked = (overrides: Record = {}) => ({ + turn: 1, + point: 'PreToolUse', + dialect: 'claude' as const, + handlerId: 'hook-1', + ...overrides, +}) + +const result = (overrides: Record = {}) => ({ + turn: 1, + point: 'PreToolUse', + handlerId: 'hook-1', + decision: 'pass', + durationMs: 3, + ...overrides, +}) + +describe('hook-protocol invariants', () => { + it('pairs serial and repeated handler invocations', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('hook/invoked', invoked()) + session.append('hook/invoked', invoked()) + session.append('hook/result', result()) + session.append('hook/result', result()) + }) + + it('rebuilds pending hook provenance from an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('hook/invoked', invoked()) + await ctx.plugin(InvariantService) + await ctx.plugin(HookInvariant) + expect(() => session.append('hook/result', result())).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('adopts a bare session first observed through publication', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-hook-session')) + expect(() => { + ctx.emit('session/event', session, { + type: 'hook/invoked', seq: 0, time: 0, data: invoked(), + }) + ctx.emit('session/event', session, { + type: 'hook/result', seq: 1, time: 1, data: result(), + }) + }).not.toThrow() + }) + + it.each([ + [invoked({ point: '' }), /point and handlerId must be non-empty/], + [invoked({ handlerId: '' }), /point and handlerId must be non-empty/], + [invoked({ dialect: 'other' }), /unknown dialect/], + ])('rejects malformed hook invocation %#', async (data, message) => { + const ctx = await setup() + expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message) + }) + + it('rejects unmatched and malformed results', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/) + session.append('hook/invoked', invoked()) + expect(() => session.append('hook/result', result({ durationMs: -1 }))) + .toThrow(/durationMs must be a non-negative finite number/) + expect(() => session.append('hook/result', result({ point: 'Stop' }))) + .toThrow(/no matching hook\/invoked/) + }) +}) diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index d092d78a00..18bc942e9f 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -1,40 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-claude`. @module @deepseek-ai/dsh-hooks-claude/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude`. + * @module @deepseek-ai/dsh-hooks-claude/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' /** Cordis companion plugin name. */ export const name = 'hooks-claude-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'hooks-claude', - inject: [ - 'bash', - ], - validate: (_fiber, effectLabels) => { - const hookEffects = [ - 'hooks-claude: drain detached hook runs', - 'ctx.on("agent/session-start")', - 'ctx.on("agent/prompt-submit")', - 'ctx.on("tools/pre-execute")', - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/turn-continuation")', - 'ctx.on("subagent/start")', - 'ctx.on("subagent/end")', - ] - const installed = hookEffects.filter(label => effectLabels.has(label)).length - return installed === 0 || installed === hookEffects.length - ? undefined - : 'a readable Claude hook config must install its complete listener set atomically' - }, - }) -} +/** + * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns + * their cross-event provenance relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -43,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/invariant.spec.ts b/packages/hooks/hooks-claude/tests/invariant.spec.ts deleted file mode 100644 index 1f7513cc64..0000000000 --- a/packages/hooks/hooks-claude/tests/invariant.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' - -describe('Claude hook package invariant', () => { - it('rejects a partially installed hook listener set', async () => { - const ctx = new Context() - await ctx.plugin({ - name: 'claude-invariant-bash', - apply(child: Context) { - child.provide('bash', { - resolve() {}, - async run() {}, - start() {}, - } as unknown as BashExecutor) - }, - }) - await expect(ctx.plugin({ - name: 'hooks-claude', - inject: ['bash'], - apply(child: Context) { - child.effect(() => () => {}, 'ctx.on("agent/session-start")') - }, - })).rejects.toThrow(/must install its complete listener set atomically/) - }) -}) diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 4fba6cea2d..5f0f6ed173 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -1,38 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-codex`. @module @deepseek-ai/dsh-hooks-codex/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-codex`. + * @module @deepseek-ai/dsh-hooks-codex/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' /** Cordis companion plugin name. */ export const name = 'hooks-codex-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'hooks-codex', - inject: [ - 'bash', - ], - validate: (_fiber, effectLabels) => { - const hookEffects = [ - 'hooks-codex: drain detached hook runs', - 'ctx.on("agent/session-start")', - 'ctx.on("agent/prompt-submit")', - 'ctx.on("tools/pre-execute")', - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/turn-continuation")', - ] - const installed = hookEffects.filter(label => effectLabels.has(label)).length - return installed === 0 || installed === hookEffects.length - ? undefined - : 'a readable Codex hook config must install its complete listener set atomically' - }, - }) -} +/** + * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns + * their cross-event provenance relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -41,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tests/invariant.spec.ts b/packages/hooks/hooks-codex/tests/invariant.spec.ts deleted file mode 100644 index bd6ee595f9..0000000000 --- a/packages/hooks/hooks-codex/tests/invariant.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' - -describe('Codex hook package invariant', () => { - it('rejects a partially installed hook listener set', async () => { - const ctx = new Context() - await ctx.plugin({ - name: 'codex-invariant-bash', - apply(child: Context) { - child.provide('bash', { - resolve() {}, - async run() {}, - start() {}, - } as unknown as BashExecutor) - }, - }) - await expect(ctx.plugin({ - name: 'hooks-codex', - inject: ['bash'], - apply(child: Context) { - child.effect(() => () => {}, 'ctx.on("agent/session-start")') - }, - })).rejects.toThrow(/must install its complete listener set atomically/) - }) -}) diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index 3327cec448..dd2df6e99c 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-deepseek`. @module @deepseek-ai/dsh-llm-deepseek/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-deepseek`. + * @module @deepseek-ai/dsh-llm-deepseek/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' /** Cordis companion plugin name. */ export const name = 'llm-deepseek-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-deepseek', - inject: [ - 'llm', - ], - effects: [ - 'llm.registerAdapter()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index ba546fb902..a096804fd2 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-pi-ai`. @module @deepseek-ai/dsh-llm-pi-ai/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-pi-ai`. + * @module @deepseek-ai/dsh-llm-pi-ai/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' /** Cordis companion plugin name. */ export const name = 'llm-pi-ai-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-pi-ai', - inject: [ - 'llm', - ], - effects: [ - 'llm.registerAdapter()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a4b8b50a3f..76d55509cb 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,30 +1,93 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm`. @module @deepseek-ai/dsh-llm/invariant */ +/** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlockType, StreamChunk } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm' /** Cordis companion plugin name. */ export const name = 'llm-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Require one chunk index to be a non-negative safe integer. */ +function validateIndex(index: number, fail: InvariantFailure): void { + if (!Number.isSafeInteger(index) || index < 0) { + fail(`LLM stream block index must be a non-negative safe integer, got ${index}`) + } +} + +/** Require a delta to address an open block of its matching type. */ +function validateDelta( + open: ReadonlyMap, + index: number, + expected: ContentBlockType, + fail: InvariantFailure, +): void { + validateIndex(index, fail) + const actual = open.get(index) + if (actual !== expected) { + fail(`${expected} delta at index ${index} requires an open ${expected} block, got ${String(actual)}`) + } +} + +/** Wrap one provider stream and enforce its grammar as chunks are consumed. */ +async function* validateStream( + source: AsyncIterable, + fail: InvariantFailure, +): AsyncIterable { + const open = new Map() + let usageSeen = false + let finished = false + for await (const chunk of source) { + if (finished) fail(`LLM stream emitted ${chunk.type} after terminal finish`) + switch (chunk.type) { + case 'block-start': + validateIndex(chunk.index, fail) + if (open.has(chunk.index)) fail(`LLM stream repeated block-start index ${chunk.index}`) + open.set(chunk.index, chunk.blockType) + break + case 'text-delta': + validateDelta(open, chunk.index, 'text', fail) + break + case 'reasoning-delta': + validateDelta(open, chunk.index, 'reasoning', fail) + break + case 'tool-call-delta': + validateDelta(open, chunk.index, 'tool-call', fail) + break + case 'block-end': { + validateIndex(chunk.index, fail) + const blockType = open.get(chunk.index) + if (blockType === undefined) fail(`LLM stream block-end index ${chunk.index} has no open block`) + if (chunk.block.type !== blockType) { + fail(`LLM stream block-end index ${chunk.index} closes ${chunk.block.type}, expected ${blockType}`) + } + open.delete(chunk.index) + break + } + case 'usage': + if (usageSeen) fail('LLM stream emitted usage more than once') + usageSeen = true + break + case 'finish': + if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`) + finished = true + break + } + yield chunk + } + if (!finished) fail('LLM stream ended without a terminal finish chunk') +} + +/** Install validation around every provider stream. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LlmService', - effects: [ - 'ctx.provide("llm")', - ], - services: [ - 'llm', - ], - }) + ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true }) } /** - * Register this package's invariant companion. + * Register the LLM invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/llm/llm/tests/invariant.spec.ts b/packages/llm/llm/tests/invariant.spec.ts new file mode 100644 index 0000000000..9eb868df1c --- /dev/null +++ b/packages/llm/llm/tests/invariant.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(LlmInvariant) + return ctx +} + +const options: GenerateOptions = { provider: 'mock', model: 'mock', messages: [] } + +async function* source(chunks: readonly StreamChunk[]): AsyncIterable { + yield* chunks +} + +async function consume(ctx: Context, chunks: readonly StreamChunk[]): Promise { + const stream = ctx.waterfall(ctx as never, 'llm/stream', options, () => source(chunks)) + const consumed: StreamChunk[] = [] + for await (const chunk of stream) consumed.push(chunk) + return consumed +} + +const finish: StreamChunk = { type: 'finish', reason: { kind: 'stop' } } + +describe('LLM stream invariants', () => { + it('accepts a complete interleaved stream grammar', async () => { + const ctx = await setup() + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'a' }, + { type: 'block-start', index: 1, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 1, text: 'b' }, + { type: 'block-end', index: 1, block: { type: 'reasoning', text: 'b' } }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'a' } }, + { type: 'block-start', index: 2, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 2, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' }, + { type: 'block-end', index: 2, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + finish, + ] + await expect(consume(ctx, chunks)).resolves.toEqual(chunks) + }) + + it.each([ + [[{ type: 'block-start', index: -1, blockType: 'text' }, finish], /non-negative safe integer/], + [[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-start', index: 0, blockType: 'text' }, + ], /repeated block-start/], + [[{ type: 'text-delta', index: 0, text: 'x' }], /requires an open text block/], + [[ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'text-delta', index: 0, text: 'x' }, + ], /got reasoning/], + [[{ type: 'block-end', index: 0, block: { type: 'text', text: '' } }], /has no open block/], + [[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: '' } }, + ], /closes reasoning, expected text/], + [[ + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + ], /usage more than once/], + [[{ type: 'block-start', index: 0, blockType: 'text' }, finish], /finished with 1 open block/], + [[finish, { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }], /usage after terminal finish/], + [[], /ended without a terminal finish/], + ] as Array<[StreamChunk[], RegExp]>)('rejects malformed stream %#', async (chunks, message) => { + const ctx = await setup() + await expect(consume(ctx, chunks)).rejects.toThrow(message) + }) + + it('preserves provider exceptions without inventing a missing-finish failure', async () => { + const ctx = await setup() + const stream = ctx.waterfall(ctx as never, 'llm/stream', options, async function* () { + throw new Error('provider failed') + }) + await expect((async () => { + for await (const _chunk of stream) { /* consume */ } + })()).rejects.toThrow('provider failed') + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..9a4345a4ad 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -60,6 +60,7 @@ class CatalogAdapter extends ScriptedAdapter { const SCRIPT: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text: 'hi' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }, { type: 'finish', reason: { kind: 'stop' } }, ] @@ -489,13 +490,14 @@ describe('LlmService', () => { const inner = next() return (async function * () { yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk + yield { type: 'block-end', index: 99, block: { type: 'text', text: '' } } satisfies StreamChunk yield * inner })() }) const chunks: StreamChunk[] = [] for await (const chunk of ctx.llm.stream({ provider: 'test-model', model: 'dynamic-model', messages: [] })) chunks.push(chunk) - expect(chunks).toHaveLength(4) + expect(chunks).toHaveLength(6) expect(chunks[0]).toMatchObject({ index: 99 }) }) diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 838a9409e8..b8f0cc385a 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-token-meter`. @module @deepseek-ai/dsh-token-meter/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-token-meter`. + * @module @deepseek-ai/dsh-token-meter/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' /** Cordis companion plugin name. */ export const name = 'token-meter-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'TokenMeterService', - effects: [ - 'ctx.provide("tokenMeter")', - 'ctx.on("session/event")', - ], - services: [ - 'tokenMeter', - ], - }) -} +/** + * No runtime invariant: token estimates are per-call outputs and the private session cache is + * invalidated at its event mutation boundary; neither exposes an independent observation stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index 3cb9dd6005..e2d8ac22cb 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-mcp-client`. @module @deepseek-ai/dsh-mcp-client/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-mcp-client`. + * @module @deepseek-ai/dsh-mcp-client/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' /** Cordis companion plugin name. */ export const name = 'mcp-client-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'mcp-client', - inject: [ - 'tools', - ], - effects: [ - 'mcp-client.serverName', - 'mcp-client.connection', - ], - }) -} +/** + * No runtime invariant: MCP generations contribute through the tool registry, but the bridge + * exposes no independent server-to-tool snapshot after an asynchronous resync. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index da6897f755..e990d46acc 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox-local`. @module @deepseek-ai/dsh-sandbox-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-local`. + * @module @deepseek-ai/dsh-sandbox-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' /** Cordis companion plugin name. */ export const name = 'sandbox-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalSandboxProvider', - effects: [ - 'ctx.provide("sandbox")', - ], - services: [ - 'sandbox', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index cd1f767df0..7ee5be733f 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -1,21 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox`. @module @deepseek-ai/dsh-sandbox/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox`. + * @module @deepseek-ai/dsh-sandbox/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' /** Cordis companion plugin name. */ export const name = 'sandbox-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'sandbox', value => serviceShapeViolation(value, { - methods: ['confine'], - })) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -24,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/tests/invariant.spec.ts b/packages/sandbox/sandbox/tests/invariant.spec.ts deleted file mode 100644 index 24854b56ae..0000000000 --- a/packages/sandbox/sandbox/tests/invariant.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { InvariantError } from '@deepseek-ai/dsh-invariants' -import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' - -class StubSandboxProvider extends SandboxProvider { - confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { - argv: [...argv], - enforcement: 'full', - denialSignatures: [], - runnerFailureSignatures: [], - } - } -} - -describe('sandbox package invariant', () => { - it('accepts a provider that exposes the confinement seam', async () => { - const ctx = new Context() - await ctx.plugin(StubSandboxProvider) - expect(ctx.sandbox).toBeInstanceOf(StubSandboxProvider) - }) - - it('rejects a service binding without confine()', async () => { - const ctx = new Context() - const invalidSandbox = { - name: 'invalid-sandbox', - apply(child: Context) { - child.provide('sandbox', {} as SandboxProvider) - }, - } - let caught: unknown - try { - await ctx.plugin(invalidSandbox) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(InvariantError) - expect(caught).toHaveProperty('packageName', '@deepseek-ai/dsh-sandbox') - expect((caught as Error).message).toMatch(/must expose method "confine"/) - }) -}) diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 417d936128..368b46fc70 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -1,35 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/create-sdk. @module @deepseek-ai/create-sdk/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/create-sdk`. + * @module @deepseek-ai/create-sdk/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' /** Cordis companion plugin name. */ export const name = 'create-sdk-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the bin-only entrypoint and its core argument mapping. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ parseCreateArgs }, packageEntry] = await Promise.all([ - import('./args.ts'), - import('./index.ts'), - ]) - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the create-sdk library entrypoint must remain empty because the package is bin-only') - const parsed = parseCreateArgs([ - 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', - ]) - assertInvariant(fail, - parsed.directory === 'workspace' - && parsed.provider === 'custom' - && parsed.baseURL === 'https://example.test' - && parsed.runInterface === 'embed' - && parsed.install === false, - 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 5faf1ea49c..9185ac8867 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-helper. @module @deepseek-ai/dsh-helper/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-helper`. + * @module @deepseek-ai/dsh-helper/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' /** Cordis companion plugin name. */ export const name = 'helper-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert FeatureId's zero-cost representation and boundary validation. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { featureId } = await import('./ids.ts') - assertInvariant(fail, featureId('local-plugin') === 'local-plugin', - 'a valid feature id must preserve its runtime string value') - let rejected = false - try { - featureId('Invalid Feature') - } catch (error) { - rejected = error instanceof Error - } - assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index 522791fe3b..72e97f3628 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -1,31 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-scripts. @module @deepseek-ai/dsh-scripts/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-scripts`. + * @module @deepseek-ai/dsh-scripts/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' /** Cordis companion plugin name. */ export const name = 'scripts-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the launcher's opaque post-separator forwarding boundary. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { splitForwardedArgs } = await import('./forwarding.ts') - const plain = splitForwardedArgs(['dev', 'src/index.ts']) - const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) - assertInvariant(fail, - plain.launcher.length === 2 - && plain.forwarded.length === 0 - && separated.launcher.length === 2 - && separated.launcher[1] === 'src/index.ts' - && separated.forwarded.length === 2 - && separated.forwarded[0] === '--inspect' - && separated.forwarded[1] === '9229', - 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 9ec704b672..c3676a1384 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-telemetry. @module @deepseek-ai/dsh-telemetry/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`. + * @module @deepseek-ai/dsh-telemetry/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' /** Cordis companion plugin name. */ export const name = 'telemetry-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the final telemetry redaction boundary removes secrets without corrupting ordinary package metadata. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ telemetryRedactionViolation }, { DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }] = await Promise.all([ - import('./redaction-contract.ts'), - import('./secret-redactor.ts'), - ]) - const redactor = new SecretRedactor() - const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) - assertInvariant(fail, - violation === undefined, - violation ?? 'telemetry redaction contract failed without a diagnostic') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index a3f3854cff..94d7c2b494 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-jsonl`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-jsonl`. * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' /** Cordis companion plugin name. */ export const name = 'session-persistence-jsonl-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionPersistenceJsonl', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionPersistence")', - ], - services: [ - 'sessionPersistence', - ], - }) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index 8eb41c061b..9d841a053d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-sqlite`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-sqlite`. * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' /** Cordis companion plugin name. */ export const name = 'session-persistence-sqlite-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionPersistenceSqlite', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionPersistence")', - ], - services: [ - 'sessionPersistence', - ], - }) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index c62805d300..316774f3fd 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -1,24 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`. * @module @deepseek-ai/dsh-session-persistence/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' /** Cordis companion plugin name. */ export const name = 'session-persistence-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'sessionPersistence', value => serviceShapeViolation(value, { - methods: ['locate', 'create', 'append', 'load', 'list'], - })) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 0f7a93acde..d087dd2378 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-session-query`. @module @deepseek-ai/dsh-session-query/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-query`. + * @module @deepseek-ai/dsh-session-query/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' /** Cordis companion plugin name. */ export const name = 'session-query-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionQueryService', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionQuery")', - ], - services: [ - 'sessionQuery', - ], - }) -} +/** + * No runtime invariant: query results are immutable per-call projections whose lineage and event + * relations are validated while they are built; the service retains no observable result state. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 7afa8c5023..6d4917a4d9 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill-local`. @module @deepseek-ai/dsh-skill-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill-local`. + * @module @deepseek-ai/dsh-skill-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' /** Cordis companion plugin name. */ export const name = 'skill-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'skill-local', - inject: [ - 'skills', - ], - effects: [ - 'skills.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index 6a673db92a..5145dee6da 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill`. @module @deepseek-ai/dsh-skill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill`. + * @module @deepseek-ai/dsh-skill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' /** Cordis companion plugin name. */ export const name = 'skill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SkillService', - effects: [ - 'ctx.provide("skills")', - ], - services: [ - 'skills', - ], - }) -} +/** + * No runtime invariant: provider/runtime maps and revisioned caches mutate atomically inside the + * registry, which exposes no independent change event or snapshot for cross-checking them. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index 8e6d6bbc3c..68d70fa2d2 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-skill`. @module @deepseek-ai/dsh-tool-skill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-skill`. + * @module @deepseek-ai/dsh-tool-skill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' /** Cordis companion plugin name. */ export const name = 'tool-skill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-skill', - inject: [ - 'tools', - 'skills', - ], - effects: [ - 'tools.register()', - 'ctx.on("agent/session-prefix")', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index b769007d7d..4b44ddbebf 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-local`. @module @deepseek-ai/dsh-spill-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill-local`. + * @module @deepseek-ai/dsh-spill-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' /** Cordis companion plugin name. */ export const name = 'spill-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalSpillStore', - effects: [ - 'ctx.provide("spillStore")', - ], - services: [ - 'spillStore', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index 4af3a81f70..82a4bee211 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -1,31 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-policy`. @module @deepseek-ai/dsh-spill-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill-policy`. + * @module @deepseek-ai/dsh-spill-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' /** Cordis companion plugin name. */ export const name = 'spill-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'spill-policy', - inject: [ - 'tools', - ], - validate: (fiber, effectLabels) => { - const installed = effectLabels.has('ctx.on("tools/post-execute")') - const enabled = (fiber.config as { maxInlineBytes?: number }).maxInlineBytes !== undefined - return installed === enabled - ? undefined - : 'the post-execute spill policy listener must exist exactly when maxInlineBytes is configured' - }, - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -34,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index ba971b8bc2..5011ac1d52 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -1,21 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill`. @module @deepseek-ai/dsh-spill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill`. + * @module @deepseek-ai/dsh-spill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' /** Cordis companion plugin name. */ export const name = 'spill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'spillStore', value => serviceShapeViolation(value, { - methods: ['saveText'], - })) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -24,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index 0c17843ff7..85c1601348 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-acp`. @module @deepseek-ai/dsh-subagent-acp/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`. + * @module @deepseek-ai/dsh-subagent-acp/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' /** Cordis companion plugin name. */ export const name = 'subagent-acp-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-acp', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index 2fc8a0f696..e3d65701b1 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-fork`. @module @deepseek-ai/dsh-subagent-fork/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-fork`. + * @module @deepseek-ai/dsh-subagent-fork/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' /** Cordis companion plugin name. */ export const name = 'subagent-fork-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-fork', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index caaf598f55..7b8bfc36e2 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-inprocess. @module @deepseek-ai/dsh-subagent-inprocess/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-inprocess`. + * @module @deepseek-ai/dsh-subagent-inprocess/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' /** Cordis companion plugin name. */ export const name = 'subagent-inprocess-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that structured-output guidance names the tool it actually installs. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') - assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), - 'the structured-output tool must retain a stable lowercase protocol name') - assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), - 'the structured-output instruction must name the exact installed tool') -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index 28201593e4..0ba0182f9f 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-spawn`. @module @deepseek-ai/dsh-subagent-spawn/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-spawn`. + * @module @deepseek-ai/dsh-subagent-spawn/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' /** Cordis companion plugin name. */ export const name = 'subagent-spawn-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-spawn', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 9eed9ae9e3..c273ce5209 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -1,37 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-subprocess. @module @deepseek-ai/dsh-subagent-subprocess/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`. + * @module @deepseek-ai/dsh-subagent-subprocess/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** Cordis companion plugin name. */ export const name = 'subagent-subprocess-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert ambient credential scrubbing and explicit credential precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { buildChildEnv } = await import('./index.ts') - const ambientProbe = `DSH_INVARIANT_AMBIENT_TOKEN_${process.pid}` - assertInvariant(fail, SENSITIVE_ENV_PATTERN.test(ambientProbe), - 'the invariant ambient probe must remain credential-shaped') - process.env[ambientProbe] = 'must-not-reach-child' - let scrubbed: NodeJS.ProcessEnv - try { - scrubbed = buildChildEnv({}) - } finally { - Reflect.deleteProperty(process.env, ambientProbe) - } - assertInvariant(fail, !Object.hasOwn(scrubbed, ambientProbe), - 'subprocess environments must omit every credential-shaped ambient variable') - - const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) - assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', - 'explicit child credentials must be applied after ambient scrubbing') -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3217a3dd9a..3c350c13a1 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,30 +1,89 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent`. @module @deepseek-ai/dsh-subagent/invariant */ +/** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SubagentProvider } from './types.ts' +import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' /** Cordis companion plugin name. */ export const name = 'subagent-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SubagentService', - effects: [ - 'ctx.provide("subagents")', - ], - services: [ - 'subagents', - ], - }) +/** Assert that a terminal lifecycle payload matches its start identity. */ +function validateRunEnd(start: SubagentRunInfo, end: SubagentRunEndInfo, fail: InvariantFailure): void { + if (start.provider !== end.provider || start.id !== end.id || start.local !== end.local) { + fail(`subagent/end identity diverges from subagent/start for run ${JSON.stringify(end.runId)}`) + } } +/** Install provider-registry and start/end pairing checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const providers = new Set(ctx.subagents.list()) + const runs = new Map() + const stagedProviders = new WeakSet() + const stagedRemovals = new Set() + const stagedStarts = new WeakSet() + const stagedEnds = new WeakSet() + + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'subagent/provider-added') { + const provider = args[0] as SubagentProvider + if (provider.name.length === 0) fail('subagent provider names must be non-empty') + if (providers.has(provider.name)) fail(`subagent/provider-added repeated ${JSON.stringify(provider.name)}`) + stagedProviders.add(provider) + return + } + if (eventName === 'subagent/provider-removed') { + const providerName = args[0] as string + if (!providers.has(providerName)) fail(`subagent/provider-removed names unknown provider ${JSON.stringify(providerName)}`) + stagedRemovals.add(providerName) + return + } + if (eventName === 'subagent/start') { + const info = args[0] as SubagentRunInfo + if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`) + if (String(info.runId).length === 0 || String(info.id).length === 0) { + fail('subagent/start runId and child id must be non-empty') + } + if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`) + stagedStarts.add(info) + return + } + if (eventName !== 'subagent/end') return + const info = args[0] as SubagentRunEndInfo + const start = runs.get(info.runId) + if (start === undefined) fail(`subagent/end has no matching subagent/start for run ${JSON.stringify(info.runId)}`) + validateRunEnd(start, info, fail) + stagedEnds.add(info) + }, { global: true }) + + ctx.on('subagent/provider-added', (provider) => { + /* v8 ignore next -- internal/dispatch stages the same provider object */ + if (!stagedProviders.delete(provider)) return + providers.add(provider.name) + }, { global: true }) + ctx.on('subagent/provider-removed', (providerName) => { + /* v8 ignore next -- internal/dispatch stages the same provider name */ + if (!stagedRemovals.delete(providerName)) return + providers.delete(providerName) + }, { global: true }) + ctx.on('subagent/start', (info) => { + /* v8 ignore next -- internal/dispatch stages the same lifecycle object */ + if (!stagedStarts.delete(info)) return + runs.set(info.runId, info) + }, { global: true }) + ctx.on('subagent/end', (info) => { + /* v8 ignore next -- internal/dispatch stages the same lifecycle object */ + if (!stagedEnds.delete(info)) return + runs.delete(info.runId) + }, { global: true }) +}, { inject: ['subagents'] }) + /** - * Register this package's invariant companion. + * Register the subagent invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts new file mode 100644 index 0000000000..ac3a919862 --- /dev/null +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import type { + SubagentProvider, + SubagentRunEndInfo, + SubagentRunInfo, +} from '@deepseek-ai/dsh-subagent' +import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(InvariantService) + await ctx.plugin(SubagentInvariant) + return ctx +} + +const provider = (name: string): SubagentProvider => ({ + name, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('not used') }, +}) + +const start = (overrides: Partial = {}): SubagentRunInfo => ({ + runId: SubagentRunId('run-1'), + provider: 'mock', + id: SessionId('child-1'), + local: false, + ...overrides, +}) + +const end = (overrides: Partial = {}): SubagentRunEndInfo => ({ + ...start(), + stopReason: 'completed', + ...overrides, +}) + +function emitRun(ctx: Context, name: 'subagent/start', info: SubagentRunInfo): void +function emitRun(ctx: Context, name: 'subagent/end', info: SubagentRunEndInfo): void +function emitRun(ctx: Context, name: 'subagent/start' | 'subagent/end', info: SubagentRunInfo | SubagentRunEndInfo): void { + ctx.emit(scopeTarget(ctx.subagents, {}), name as 'subagent/start', info) +} + +describe('subagent invariants', () => { + it('accepts provider and run lifecycle pairs', async () => { + const ctx = await setup() + const mock = provider('mock') + ctx.emit('subagent/provider-added', mock) + emitRun(ctx, 'subagent/start', start()) + emitRun(ctx, 'subagent/end', end()) + ctx.emit('subagent/provider-removed', 'mock') + ctx.emit('tools/change') + }) + + it('rejects malformed provider transitions', async () => { + const ctx = await setup() + expect(() => { ctx.emit('subagent/provider-added', provider('')) }).toThrow(/names must be non-empty/) + const mock = provider('mock') + ctx.emit('subagent/provider-added', mock) + expect(() => { ctx.emit('subagent/provider-added', mock) }).toThrow(/repeated "mock"/) + expect(() => { ctx.emit('subagent/provider-removed', 'missing') }).toThrow(/unknown provider/) + }) + + it('rejects malformed and unpaired run transitions', async () => { + const ctx = await setup() + expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/) + ctx.emit('subagent/provider-added', provider('mock')) + expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) }) + .toThrow(/runId and child id must be non-empty/) + emitRun(ctx, 'subagent/start', start()) + expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/) + expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) }) + .toThrow(/no matching subagent\/start/) + expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) }) + .toThrow(/identity diverges/) + }) +}) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index b1bfa209a1..bd30f4c563 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-subagent`. @module @deepseek-ai/dsh-tool-subagent/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent`. + * @module @deepseek-ai/dsh-tool-subagent/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' /** Cordis companion plugin name. */ export const name = 'tool-subagent-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-subagent', - inject: [ - 'tools', - 'subagents', - ], - effects: [ - 'ctx.on("subagent/provider-added")', - 'ctx.on("subagent/provider-removed")', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index ac29b83cff..e94876100e 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-acp-snapshot. @module @deepseek-ai/dsh-acp-snapshot/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp-snapshot`. + * @module @deepseek-ai/dsh-acp-snapshot/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' /** Cordis companion plugin name. */ export const name = 'acp-snapshot-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert stable JSON-RPC correlation and volatile-value tokenization. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { normalizeStdout } = await import('./normalize.ts') - const sessionId = '12345678-1234-1234-1234-123456789abc' - const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } - const raw = [ - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), - ].join('\n') - const normalized = normalizeStdout(raw, volatile) - assertInvariant(fail, - normalized.includes('"id":1') - && normalized.includes('"cwd":"{{cwd}}"') - && normalized.includes('"sessionId":"{{sessionId}}"'), - 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') - assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, - 'ACP stdout normalization must be idempotent') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index 69792b77ea..33ee4474f9 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -1,25 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-agent-loop-testkit. @module @deepseek-ai/dsh-agent-loop-testkit/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-loop-testkit`. + * @module @deepseek-ai/dsh-agent-loop-testkit/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' /** Cordis companion plugin name. */ export const name = 'agent-loop-testkit-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the awaitable helper shape and optional-options call boundary. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { mountAgentLoopTestDependencies } = await import('./index.ts') - assertInvariant(fail, - mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', - 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') - assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, - 'the prerequisite mount helper must keep its options argument optional') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 7b922bcda9..1d5a377936 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -12,38 +12,37 @@ interface Config { } ``` -Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the allowlist is empty or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. `ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Synchronous or asynchronous installer completion is joined before registration succeeds; failure disposes the child and releases ownership atomically. -The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. +The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes listeners, trace state, and the reservation. A companion can therefore reload and register the same package name without retaining its previous state. Session-backed companions rebuild their baseline from durable events; live-only companions observe operations that begin after reload. -`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service. +`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product dependency to the service. ## Package companions -Every companion installs at least one executable, package-specific contract and reports failure through its bound reporter. There is no generated or ownership-only baseline. `pnpm run verify-package-invariants` rejects generated markers, empty installers, installers that ignore the reporter, duplicate name-based plugin observers, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. +Publication and registration are exhaustive; runtime assertions are deliberately not synthetic. A companion installs a check only when its package owns an observable event relationship or relevant mutable-data relationship. Confirming a required method, plugin name, injection, effect, or fixed pure-function result is a type, load, or unit-test concern rather than a runtime invariant. -Packages select the narrowest runtime form that protects their public contract: +When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their seam, composition-only packages, binaries, persistence adapters whose contracts require crash/round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. -| Package shape | Companion check | +The current executable companions protect these relationships: + +| Companion | Checks | |---|---| -| Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | -| Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | -| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape during child startup. | +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | +| `dsh-llm`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | +| `dsh-compact`, `dsh-hook-protocol`, `dsh-bash` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | +| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | +| `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | +| `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | +| `dsh-time-context` | Durable clock readings agree with their turn, step, elapsed baseline, and event timestamp. | -Four companions additionally install stateful event and request checks: +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. -| Companion | Registration | Checks | -|---|---|---| -| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | -| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | -| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | -| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | - -The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. Name-based plugin observers match only a fiber's own declared runtime name, not anonymous child fibers that inherit a parent display name. They avoid importing the product entrypoint before it is loaded; pure-library checks likewise defer owner imports into the installer child so Vitest mocks and deployment loaders establish their module boundary first. +`pnpm run verify-package-invariants` discovers all workspace packages. It rejects generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. This source rule is a minimum ownership check; focused tests prove each executable companion's semantics. ## Composition @@ -62,11 +61,13 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Plugin and service helpers multiplex package contracts through indexed lifecycle listeners shared by the Cordis root, while contribution disposal removes only that owner's contract. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology test mounts all companions once, and focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. + +Every ordinary Vitest topology mounts an explicitly enabled service and the current test package's companion. Focused suites cover valid and invalid observations for executable companions, while one exhaustive topology mounts all companions to prove registration and disposal wiring. ## Model Experience -None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results. +None. The service and companions observe runtime events, mutable snapshots, and requests but never alter prompts, messages, schemas, streams, or tool results. #### KV Cache effect @@ -74,7 +75,6 @@ None; invariant checks do not assemble or send provider requests. ## Known Limitations and Deferred Work -- A name-based plugin observer assumes Cordis plugin names are unique within one root; a package can provide the exact callback when importing it does not preload an unrelated runtime. -- Pure-library contracts are sampled when their companion child activates rather than observed continuously; mutable package behavior belongs on an event, service, or plugin-fiber observer. -- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. +- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot LLM calls remain outside that marker contract. +- Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 265596b677..43216baa1e 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-invariants */ -import { Context, FiberState, Service } from 'cordis' -import type { Fiber, Inject, Plugin } from 'cordis' +import { Context, Service } from 'cordis' +import type { Inject } from 'cordis' import z from 'schemastery' import type Schema from 'schemastery' @@ -41,300 +41,6 @@ export interface InvariantInstaller { readonly inject?: Inject } -/** Runtime facts one package expects from its Cordis plugin fiber. */ -export interface PluginInvariantContract { - /** Exact plugin value when checking it does not preload an unrelated runtime; otherwise matching uses `name`. */ - readonly plugin?: Plugin - /** Exact Cordis display name for the plugin fiber. */ - readonly name: string - /** Required service injections that must be present when the fiber activates. */ - readonly inject?: readonly string[] - /** Required owned effect labels; an inner array means at least one alternative must exist. */ - readonly effects?: readonly (string | readonly string[])[] - /** Services the active fiber must provide. */ - readonly services?: readonly string[] - /** Optional package-owned validation after the structural checks pass. */ - readonly validate?: (fiber: Fiber, effectLabels: ReadonlySet) => string | undefined -} - -/** Collect all live effect labels below a plugin fiber. */ -function collectEffectLabels(fiber: Fiber): ReadonlySet { - const labels = new Set() - const visit = (effects: ReturnType): void => { - for (const effect of effects) { - labels.add(effect.label) - visit(effect.children) - } - } - visit(fiber.getEffects()) - return labels -} - -/** One package check routed by a root-shared plugin lifecycle dispatcher. */ -interface PluginObservation { - readonly callback: globalThis.Function | undefined - readonly contract: PluginInvariantContract - readonly fail: InvariantFailure -} - -/** Indexed plugin checks and the two lifecycle listeners shared by one root. */ -interface PluginObservationHub { - readonly byCallback: Map> - readonly byName: Map> -} - -const pluginObservationHubs = new WeakMap() - -/** Check one already-matched active plugin fiber. */ -function inspectPluginObservation(observation: PluginObservation, fiber: Fiber): void { - if (fiber.state !== FiberState.ACTIVE || fiber.uid === null) return - const { callback, contract, fail } = observation - if (callback !== undefined && fiber.name !== contract.name) { - fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) - } - const injections = new Set(Object.keys(fiber.inject)) - for (const service of contract.inject ?? []) { - if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) - } - - const effectLabels = collectEffectLabels(fiber) - for (const requirement of contract.effects ?? []) { - const alternatives = typeof requirement === 'string' ? [requirement] : requirement - if (!alternatives.some(label => effectLabels.has(label))) { - fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) - } - } - for (const service of contract.services ?? []) { - const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { - const implementation = fiber.ctx.reflect.store[key as symbol] - return implementation?.fiber === fiber && implementation.name === service - }) - if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) - } - const message = contract.validate?.(fiber, effectLabels) - if (message !== undefined) fail(message) -} - -/** Route one lifecycle notification only to checks that can match its runtime. */ -function inspectObservedPlugin(hub: PluginObservationHub, fiber: Fiber): void { - const callback = fiber.runtime?.callback - if (callback !== undefined) { - for (const observation of hub.byCallback.get(callback) ?? []) { - inspectPluginObservation(observation, fiber) - } - } - const runtimeName = fiber.runtime?.name - if (runtimeName !== undefined) { - for (const observation of hub.byName.get(runtimeName) ?? []) { - inspectPluginObservation(observation, fiber) - } - } -} - -/** Return the root's shared plugin dispatcher, creating its two listeners once. */ -function pluginObservationHub(ctx: Context): PluginObservationHub { - const root = ctx.root - const existing = pluginObservationHubs.get(root) - if (existing !== undefined) return existing - - const hub: PluginObservationHub = { - byCallback: new Map(), - byName: new Map(), - } - pluginObservationHubs.set(root, hub) - root.on('internal/plugin', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) - root.on('internal/status', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) - return hub -} - -/** Add one plugin observation to a typed exact-key index. */ -function addIndexedPluginObservation( - index: Map>, - key: Key, - observation: PluginObservation, -): () => void { - const observations = index.get(key) ?? new Set() - index.set(key, observations) - observations.add(observation) - return () => { - observations.delete(observation) - if (observations.size === 0) index.delete(key) - } -} - -/** Add one observation to its exact callback or runtime-name index. */ -function addPluginObservation(hub: PluginObservationHub, observation: PluginObservation): () => void { - if (observation.callback === undefined) { - return addIndexedPluginObservation(hub.byName, observation.contract.name, observation) - } - return addIndexedPluginObservation(hub.byCallback, observation.callback, observation) -} - -/** - * Observe one package plugin and fail whenever an active fiber violates its - * declared name, dependency, effect, service, or package-specific contract. - * Existing fibers are checked immediately; later starts and HMR activations - * are checked through two indexed lifecycle listeners shared by the root. - * @param ctx - invariant child context that owns the observers. - * @param fail - reporter bound to the package that owns the plugin. - * @param contract - expected runtime facts for the package plugin. - * @returns nothing after lifecycle observers are installed. - */ -export function observePluginInvariant( - ctx: Context, - fail: InvariantFailure, - contract: PluginInvariantContract, -): void { - const callback = contract.plugin === undefined ? undefined : ctx.registry.resolve(contract.plugin) - if (contract.plugin !== undefined && callback === undefined) { - fail('invariant contract does not identify a Cordis plugin') - } - - const observation: PluginObservation = { callback, contract, fail } - - if (contract.plugin === undefined) { - for (const runtime of ctx.registry.values()) { - if (runtime.name !== contract.name) continue - for (const fiber of runtime.fibers) inspectPluginObservation(observation, fiber) - } - } else { - for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) { - inspectPluginObservation(observation, fiber) - } - } - const hub = pluginObservationHub(ctx) - ctx.effect( - () => addPluginObservation(hub, observation), - `invariants.observePlugin(${JSON.stringify(contract.name)})`, - ) -} - -/** One structural check routed by a root-shared service lifecycle dispatcher. */ -interface ServiceObservation { - readonly fail: InvariantFailure - readonly validate: (value: unknown) => string | undefined -} - -/** Service checks and the single service listener shared by one root. */ -interface ServiceObservationHub { - readonly byName: Map> -} - -const serviceObservationHubs = new WeakMap() - -/** Check one present service implementation. */ -function inspectServiceObservation(observation: ServiceObservation, value: unknown): void { - if (value === undefined) return - const message = observation.validate(value) - if (message !== undefined) observation.fail(message) -} - -/** Return the root's shared service dispatcher, creating its listener once. */ -function serviceObservationHub(ctx: Context): ServiceObservationHub { - const root = ctx.root - const existing = serviceObservationHubs.get(root) - if (existing !== undefined) return existing - - const hub: ServiceObservationHub = { byName: new Map() } - serviceObservationHubs.set(root, hub) - root.on('internal/service', (name, value: unknown) => { - for (const observation of hub.byName.get(name) ?? []) { - inspectServiceObservation(observation, value) - } - }, { global: true }) - return hub -} - -/** Add one service observation to its exact service-name index. */ -function addServiceObservation( - hub: ServiceObservationHub, - serviceName: string, - observation: ServiceObservation, -): () => void { - const observations = hub.byName.get(serviceName) ?? new Set() - hub.byName.set(serviceName, observations) - observations.add(observation) - return () => { - observations.delete(observation) - if (observations.size === 0) hub.byName.delete(serviceName) - } -} - -/** - * Validate every current and future implementation bound to one Cordis - * service through the root's indexed shared service listener. - * @param ctx - invariant child context that owns the service observer. - * @param fail - reporter bound to the package that owns the service seam. - * @param serviceName - Cordis service name to observe. - * @param validate - returns the violated contract, or `undefined` for a valid implementation. - * @returns nothing after the current binding is checked and the observer is installed. - */ -export function observeServiceInvariant( - ctx: Context, - fail: InvariantFailure, - serviceName: string, - validate: (value: unknown) => string | undefined, -): void { - const observation: ServiceObservation = { fail, validate } - const current: unknown = ctx.get(serviceName) - inspectServiceObservation(observation, current) - const hub = serviceObservationHub(ctx) - ctx.effect( - () => addServiceObservation(hub, serviceName, observation), - `invariants.observeService(${JSON.stringify(serviceName)})`, - ) -} - -/** Structural runtime surface required from a Cordis service implementation. */ -export interface ServiceShapeInvariant { - /** Members that must be callable. */ - readonly methods: readonly string[] - /** Members that must be non-empty strings. */ - readonly stringProperties?: readonly string[] -} - -/** - * Describe the first missing member in a structural service implementation. - * This deliberately accepts test doubles and third-party implementations that - * satisfy the seam without inheriting the first-party abstract service class. - * @param value - candidate service implementation. - * @param shape - callable and string members owned by the service package. - * @returns the violated shape, or `undefined` when the candidate conforms. - */ -export function serviceShapeViolation( - value: unknown, - shape: ServiceShapeInvariant, -): string | undefined { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return 'service implementation must be an object' - } - const record = value as Record - for (const method of shape.methods) { - if (typeof record[method] !== 'function') return `service implementation must expose method ${JSON.stringify(method)}` - } - for (const property of shape.stringProperties ?? []) { - if (typeof record[property] !== 'string' || record[property].length === 0) { - return `service implementation must expose non-empty string ${JSON.stringify(property)}` - } - } - return undefined -} - -/** - * Report a failed package-owned synchronous invariant. - * @param fail - reporter bound to the package that owns the assertion. - * @param condition - condition that must hold. - * @param message - violated contract when `condition` is false. - * @returns nothing when the condition holds. - */ -export function assertInvariant( - fail: InvariantFailure, - condition: unknown, - message: string, -): void { - if (!condition) fail(message) -} - /** Internal effect shape used to join child startup before a companion loads. */ interface PendingInvariantRegistration extends PromiseLike<() => void> { (): void | Promise diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index bd9efcb74c..7780e987f5 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-invariants`. @module @deepseek-ai/dsh-invariants/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-invariants`. + * @module @deepseek-ai/dsh-invariants/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import InvariantService, { observePluginInvariant, type InvariantInstaller } from './index.ts' +import type { InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' /** Cordis companion plugin name. */ export const name = 'invariants-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - plugin: InvariantService, - name: 'InvariantService', - effects: [ - 'ctx.provide("invariants")', - ], - services: [ - 'invariants', - ], - }) -} +/** + * No runtime invariant: registration ownership and child lifecycle are the service's mutation + * boundary itself; observing them from the same registry would only duplicate its implementation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 76fda543ad..b190379302 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -2,19 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import InvariantService, { InvariantError, - assertInvariant, - observePluginInvariant, - observeServiceInvariant, - serviceShapeViolation, type Config, - type InvariantInstaller, - type PluginInvariantContract, } from '@deepseek-ai/dsh-invariants' declare module 'cordis' { interface Context { invariantProbe: InvariantProbeService - watchedInvariantProbe: WatchedInvariantProbeService } interface Events { @@ -28,12 +21,6 @@ class InvariantProbeService extends Service { } } -class WatchedInvariantProbeService extends Service { - constructor(ctx: Context) { - super(ctx, 'watchedInvariantProbe') - } -} - interface RuntimeRegistration extends PromiseLike<() => void> { (): void | Promise } @@ -299,277 +286,3 @@ describe('InvariantService lifecycle', () => { expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) }) }) - -describe('package-owned invariant helpers', () => { - interface InvariantDisposer { - (): void | Promise - } - - async function registerInstaller( - ctx: Context, - packageName: string, - installer: InvariantInstaller, - ): Promise { - const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) - const dispose = await Promise.resolve(registration) - return dispose - } - - function effectPlugin(options: { - name?: string - inject?: string[] - effect?: string - service?: string - } = {}) { - return { - name: options.name ?? 'effect-probe', - inject: options.inject ?? [], - apply(ctx: Context) { - if (options.service !== undefined) ctx.provide(options.service, {}) - if (options.effect !== undefined) { - ctx.effect(() => { - ctx.effect(() => () => {}, `${options.effect}.child`) - return () => {} - }, options.effect) - } - }, - } - } - - async function expectPluginViolation( - contract: PluginInvariantContract, - plugin: ReturnType, - message: RegExp, - ): Promise { - const { ctx } = await setup() - await registerInstaller(ctx, `@deepseek-ai/${contract.name}`, (child, fail) => { - observePluginInvariant(child, fail, contract) - }) - await expect(Promise.resolve(ctx.plugin(plugin))).rejects.toThrow(message) - } - - it('checks existing and later plugin fibers, including nested effects and alternatives', async () => { - const { ctx } = await setup() - await ctx.plugin(InvariantProbeService) - const plugin = effectPlugin({ - inject: ['invariantProbe'], - effect: 'probe.effect', - service: 'pluginProbe', - }) - await ctx.plugin(plugin) - const validated = vi.fn(() => undefined) - await registerInstaller(ctx, '@deepseek-ai/dsh-existing-probe', (child, fail) => { - observePluginInvariant(child, fail, { - plugin, - name: 'effect-probe', - inject: ['invariantProbe'], - effects: [['missing.effect', 'probe.effect.child']], - services: ['pluginProbe'], - validate: validated, - }) - }) - expect(validated).toHaveBeenCalledOnce() - - const later = effectPlugin({ name: 'later-probe', effect: 'later.effect' }) - await registerInstaller(ctx, '@deepseek-ai/dsh-later-probe', (child, fail) => { - observePluginInvariant(child, fail, { - plugin: later, - name: 'later-probe', - effects: ['later.effect'], - }) - }) - await ctx.plugin(later) - }) - - it('matches package plugins by Cordis name without importing their callback', async () => { - const { ctx } = await setup() - const plugin = { - name: 'name-only-probe', - apply(pluginCtx: Context) { - pluginCtx.effect(() => () => {}, 'name-only.effect') - pluginCtx.inject([], () => {}) - }, - } - await registerInstaller(ctx, '@deepseek-ai/dsh-name-only-probe', (child, fail) => { - observePluginInvariant(child, fail, { - name: 'name-only-probe', - effects: ['name-only.effect'], - }) - }) - await ctx.plugin(plugin) - }) - - it('multiplexes same-runtime plugin checks through one root listener pair and disposes each owner', async () => { - const { ctx } = await setup() - const firstValidation = vi.fn(() => undefined) - const secondValidation = vi.fn(() => undefined) - const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-first', (child, fail) => { - observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: firstValidation }) - }) - const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-second', (child, fail) => { - observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: secondValidation }) - }) - const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/plugin")')).toHaveLength(1) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/status")')).toHaveLength(1) - - const plugin = effectPlugin({ name: 'shared-plugin-probe' }) - const firstFiber = await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledOnce() - - await first() - await firstFiber.dispose() - const secondFiber = await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledTimes(2) - - await second() - await secondFiber.dispose() - await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledTimes(2) - }) - - it('rejects a contract that does not identify a plugin', async () => { - const { ctx } = await setup() - const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { - observePluginInvariant(child, fail, { - plugin: {} as never, - name: 'invalid-plugin', - }) - })) - await expect(Promise.resolve(registration)).rejects.toThrow(/does not identify a Cordis plugin/) - }) - - it('rejects wrong plugin names, missing injections, effects, services, and custom checks', async () => { - const wrongName = effectPlugin({ name: 'actual-name', effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: wrongName, - name: 'expected-name', - }, wrongName, /plugin name must be "expected-name"/) - - const missingInjection = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: missingInjection, - name: 'effect-probe', - inject: ['missingService'], - }, missingInjection, /must inject "missingService"/) - - const missingEffect = effectPlugin() - await expectPluginViolation({ - plugin: missingEffect, - name: 'effect-probe', - effects: [['first.effect', 'second.effect']], - }, missingEffect, /must own effect "first.effect" or "second.effect"/) - - const missingService = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: missingService, - name: 'effect-probe', - services: ['missingService'], - }, missingService, /must provide service "missingService"/) - - const invalidCustom = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: invalidCustom, - name: 'effect-probe', - validate: () => 'custom plugin contract failed', - }, invalidCustom, /custom plugin contract failed/) - }) - - it('checks existing and future service implementations while ignoring unrelated changes', async () => { - const existing = await setup() - await existing.ctx.plugin(WatchedInvariantProbeService) - await registerInstaller(existing.ctx, '@deepseek-ai/dsh-existing-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( - value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' - )) - }) - - const future = await setup() - await registerInstaller(future.ctx, '@deepseek-ai/dsh-future-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( - value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' - )) - }) - await future.ctx.plugin(InvariantProbeService) - await future.ctx.plugin(WatchedInvariantProbeService) - - const invalid = await setup() - await registerInstaller(invalid.ctx, '@deepseek-ai/dsh-invalid-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', () => 'wrong watched service') - }) - await expect(Promise.resolve(invalid.ctx.plugin(WatchedInvariantProbeService))) - .rejects.toThrow(/wrong watched service/) - }) - - it('multiplexes same-name service checks through one root listener and disposes each owner', async () => { - const { ctx } = await setup() - const firstValidation = vi.fn(() => undefined) - const secondValidation = vi.fn(() => undefined) - const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-first', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', firstValidation) - }) - const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-second', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', secondValidation) - }) - const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/service")')).toHaveLength(1) - - const firstFiber = await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledOnce() - - await first() - const firstCallsAfterDisposal = firstValidation.mock.calls.length - const secondCallsBeforeRemount = secondValidation.mock.calls.length - await firstFiber.dispose() - const secondFiber = await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterDisposal) - expect(secondValidation.mock.calls.length).toBeGreaterThan(secondCallsBeforeRemount) - - await second() - const firstCallsAfterBothDisposals = firstValidation.mock.calls.length - const secondCallsAfterBothDisposals = secondValidation.mock.calls.length - await secondFiber.dispose() - await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterBothDisposals) - expect(secondValidation).toHaveBeenCalledTimes(secondCallsAfterBothDisposals) - }) - - it('reports synchronous package assertions through the bound failure reporter', async () => { - const { ctx } = await setup() - const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { - assertInvariant(fail, true, 'must stay true') - }) - await valid() - - const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { - assertInvariant(fail, false, 'must stay true') - })) - await expect(Promise.resolve(invalid)).rejects.toThrow(/must stay true/) - }) - - it('accepts structural service implementations and test doubles', () => { - expect(serviceShapeViolation({ kind: 'probe', run() {} }, { - methods: ['run'], - stringProperties: ['kind'], - })).toBeUndefined() - expect(serviceShapeViolation(Object.assign(() => {}, { run() {} }), { - methods: ['run'], - })).toBeUndefined() - }) - - it.each([ - { value: null, message: 'service implementation must be an object' }, - { value: 42, message: 'service implementation must be an object' }, - { value: {}, message: 'service implementation must expose method "run"' }, - { value: { run() {}, kind: '' }, message: 'service implementation must expose non-empty string "kind"' }, - ])('rejects invalid structural service implementations: $message', ({ value, message }) => { - expect(serviceShapeViolation(value, { - methods: ['run'], - stringProperties: ['kind'], - })).toBe(message) - }) -}) diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 9c82c33686..36a3f8eeca 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-replay`. @module @deepseek-ai/dsh-llm-replay/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-replay`. + * @module @deepseek-ai/dsh-llm-replay/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' /** Cordis companion plugin name. */ export const name = 'llm-replay-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-replay', - inject: [ - 'llm', - ], - effects: [ - [ - 'llm.registerAdapter()', - 'ctx.on("llm/stream")', - ], - ], - }) -} +/** + * No runtime invariant: this test-only adapter consumes a fixed replay script; its stream grammar + * is checked by the LLM companion and fixture derivation tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 6fbb41a123..1e3cc54b81 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -1,32 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-loader-smoke. @module @deepseek-ai/dsh-loader-smoke/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-loader-smoke`. + * @module @deepseek-ai/dsh-loader-smoke/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' /** Cordis companion plugin name. */ export const name = 'loader-smoke-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert default source mode and plain-Node built-artifact launch resolution. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') - assertInvariant(fail, resolveExampleMode('') === 'src', - 'an empty example-mode selection must preserve source-mode development') - const launch = resolveExampleLaunch({ - srcBin: '/workspace/probe/src/bin.ts', - mode: 'lib', - }) - assertInvariant(fail, - launch.command === process.execPath - && launch.args.length === 1 - && launch.args[0] === '/workspace/probe/lib/bin.js' - && launch.env.TSX_TSCONFIG_PATH === undefined, - 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index ce3bf24175..a633213607 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,31 +1,55 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tasks`. @module @deepseek-ai/dsh-tasks/invariant */ +/** Package-owned background-task snapshot invariants. @module @deepseek-ai/dsh-tasks/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { TaskSnapshot } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' +const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed']) /** Cordis companion plugin name. */ export const name = 'tasks-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'TaskService', - effects: [ - 'ctx.provide("tasks")', - 'tasks teardown', - ], - services: [ - 'tasks', - ], - }) +/** Validate the cross-field relationships in one registry snapshot. */ +function validateSnapshot(snapshot: TaskSnapshot, owner: Agent | undefined, fail: InvariantFailure): void { + const id = String(snapshot.id) + const prefix = `${snapshot.kind}-` + const ordinal = Number(id.slice(prefix.length)) + if (snapshot.kind.length === 0 || !id.startsWith(prefix) + || !Number.isSafeInteger(ordinal) || ordinal < 1) { + fail(`task snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + } + if (snapshot.label.length === 0) fail(`task ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + fail(`task ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) + } + + const terminal = TERMINAL_STATUSES.has(snapshot.status) + if (terminal !== (snapshot.finishedAt !== undefined)) { + fail(`task ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) + } + if (snapshot.finishedAt !== undefined + && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + fail(`task ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) + } + + const expectedOwner = owner?.id + if (snapshot.ownerSession !== expectedOwner) { + fail(`task ${JSON.stringify(id)} ownerSession does not match its completion owner`) + } } +/** Install checks over current unowned records and every terminal snapshot. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const snapshot of ctx.tasks.list()) validateSnapshot(snapshot, undefined, fail) + ctx.tasks.onTaskDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) }) +}, { inject: ['tasks'] }) + /** - * Register this package's invariant companion. + * Register the task-registry invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/tasks/tasks/tests/invariant.spec.ts b/packages/tasks/tasks/tests/invariant.spec.ts new file mode 100644 index 0000000000..e23609df5d --- /dev/null +++ b/packages/tasks/tasks/tests/invariant.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' +import type { TaskDoneListener, TaskSnapshot } from '@deepseek-ai/dsh-tasks' +import * as TasksInvariant from '@deepseek-ai/dsh-tasks/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +const BASE: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + finishedAt: 20, + reported: false, +} + +const RUNNING: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'running', + startedAt: 10, + reported: false, +} + +const TERMINAL_WITHOUT_FINISH: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + reported: false, +} + +async function setup(seed: TaskSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> { + const ctx = new Context() + let listener: TaskDoneListener | undefined + const probe = { + list: () => seed, + onTaskDone(value: TaskDoneListener) { + listener = value + return () => { listener = undefined } + }, + } as unknown as TaskService + await ctx.plugin(InvariantService) + await ctx.plugin({ + name: 'task-invariant-probe', + apply(child: Context) { child.provide('tasks', probe) }, + }) + await ctx.plugin(TasksInvariant) + if (listener === undefined) throw new Error('task invariant did not subscribe to terminal snapshots') + return (snapshot, owner) => { listener!(snapshot as TaskSnapshot, owner) } +} + +describe('task-registry invariants', () => { + it('accepts coherent current and terminal snapshots', async () => { + const notify = await setup([RUNNING]) + expect(() => { notify(BASE) }).not.toThrow() + const owner = { id: SessionId('owner') } as Agent + expect(() => { notify({ ...BASE, id: TaskId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) + .not.toThrow() + }) + + it.each([ + [{ ...BASE, id: TaskId('-1'), kind: '' }, undefined, /positive ordinal/], + [{ ...BASE, id: TaskId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: TaskId('bash-x') }, undefined, /positive ordinal/], + [{ ...BASE, id: TaskId('bash-0') }, undefined, /positive ordinal/], + [{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/], + [TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], + [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], + [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], + ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { + const notify = await setup() + expect(() => { notify(snapshot, owner) }).toThrow(message) + }) + + it('rejects an incoherent record already present at installation', async () => { + await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) + }) +}) diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index e4af0931d2..cedad9dc1c 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-tasks`. @module @deepseek-ai/dsh-tool-tasks/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-tasks`. + * @module @deepseek-ai/dsh-tool-tasks/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' /** Cordis companion plugin name. */ export const name = 'tool-tasks-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-tasks', - inject: [ - 'tools', - 'tasks', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index b0e564dbbd..ddc3b3966e 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-timeout-policy`. @module @deepseek-ai/dsh-timeout-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-timeout-policy`. + * @module @deepseek-ai/dsh-timeout-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' /** Cordis companion plugin name. */ export const name = 'timeout-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'timeout-policy', - inject: [ - 'tools', - ], - effects: [ - 'ctx.on("tools/execute")', - ], - }) -} +/** + * No runtime invariant: this stateless policy plugin owns no package-local event history or mutable + * data relation beyond the seam it intercepts. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 9ad57156f4..0b3a3739e4 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,30 +1,49 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-todo`. @module @deepseek-ai/dsh-tool-todo/invariant */ +/** Package-owned durable todo-snapshot invariants. @module @deepseek-ai/dsh-tool-todo/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' +const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed']) /** Cordis companion plugin name. */ export const name = 'tool-todo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate one whole-list todo snapshot before it reaches the durable log. */ +function validateTodos(value: unknown, fail: InvariantFailure): void { + if (!Array.isArray(value)) fail('todo/write todos must be an array') + const seen = new Set() + let active = 0 + for (const item of value) { + if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects') + const { content, status } = item as Record + if (typeof content !== 'string' || content.length === 0 || content.trim() !== content) { + fail('todo/write content must be non-empty and already trimmed') + } + if (seen.has(content)) fail(`todo/write repeats content ${JSON.stringify(content)}`) + seen.add(content) + if (typeof status !== 'string' || !TODO_STATUSES.has(status)) { + fail(`todo/write carries unknown status ${JSON.stringify(status)}`) + } + if (status === 'in_progress') active += 1 + } + if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`) +} + +/** Install validation for durable whole-list todo snapshots. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-todo', - inject: [ - 'tools', - ], - effects: [ - 'tools.register()', - ], - }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'todo/write') validateTodos(event.data.todos, fail) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the todo invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts new file mode 100644 index 0000000000..8593f60299 --- /dev/null +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(TodoInvariant) + return ctx +} + +function event(todos: unknown): SessionEvent { + return { type: 'todo/write', seq: 0, time: 0, data: { todos } } as SessionEvent +} + +describe('todo snapshot invariants', () => { + it('accepts a unique whole-list snapshot with one active item', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, event([ + { content: 'Inspect state', status: 'completed' }, + { content: 'Apply fix', status: 'in_progress' }, + { content: 'Run checks', status: 'pending' }, + ])) }).not.toThrow() + }) + + it.each([ + ['not-an-array', /must be an array/], + [[null], /entries must be objects/], + [[42], /entries must be objects/], + [[{ content: 42, status: 'pending' }], /content must be non-empty/], + [[{ content: '', status: 'pending' }], /content must be non-empty/], + [[{ content: ' padded ', status: 'pending' }], /already trimmed/], + [[{ content: 'same', status: 'pending' }, { content: 'same', status: 'completed' }], /repeats content/], + [[{ content: 'task', status: 42 }], /unknown status/], + [[{ content: 'task', status: 'paused' }], /unknown status/], + [[{ content: 'one', status: 'in_progress' }, { content: 'two', status: 'in_progress' }], /at most one/], + ])('rejects an incoherent durable todo snapshot', async (todos, message) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message) + }) + + it('ignores unrelated dispatches and session events', async () => { + const ctx = await setup() + expect(() => { + ctx.emit('tools/change') + ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + }).not.toThrow() + }) +}) diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 1b98d6630c..fdefcf291e 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp`. @module @deepseek-ai/dsh-acp/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp`. + * @module @deepseek-ai/dsh-acp/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' /** Cordis companion plugin name. */ export const name = 'acp-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'acp', - inject: [ - 'agents', - 'sessionPersistence', - 'tools', - 'userInteraction', - 'llm', - 'systemPrompt', - ], - effects: [ - 'userInteraction.registerProvider()', - 'ctx.on("session/event")', - 'acp.connection', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -37,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 8591c60dd1..0dacba6e40 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-app-boot. @module @deepseek-ai/dsh-app-boot/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`. + * @module @deepseek-ai/dsh-app-boot/invariant + */ /* jscpd:ignore-start */ -import { resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' /** Cordis companion plugin name. */ export const name = 'app-boot-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert ordinary and replay config-path selection. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { resolveConfigPath } = await import('./config-path.ts') - const cwd = '/tmp/dsh-app-boot-invariant' - const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) - const replay = resolveConfigPath('cordis.yml', 'replay', cwd) - assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), - 'ordinary app boot must retain the requested config basename') - assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), - 'snapshot replay must select cordis.snapshot.yml in the requested config directory') -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index aad2ac9f41..1a3c9b053b 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-jsonrpc`. @module @deepseek-ai/dsh-jsonrpc/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc`. + * @module @deepseek-ai/dsh-jsonrpc/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' /** Cordis companion plugin name. */ export const name = 'jsonrpc-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'jsonrpc', - inject: [ - 'agents', - ], - effects: [ - 'jsonrpc.serve', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 24191712bf..0be5942d24 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -1,34 +1,29 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-permission`. @module @deepseek-ai/dsh-permission/invariant */ +/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' /** Cordis companion plugin name. */ export const name = 'permission-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'PermissionService', - inject: [ - 'bash', - 'approval', - ], - effects: [ - 'ctx.provide("permission")', - ], - services: [ - 'permission', - ], - }) -} +/** Install validation that durable preset events remain resolvable. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: (message: string) => never) => { + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { + fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) + } + }, { global: true }) +}, { inject: ['permission'] }) /** - * Register this package's invariant companion. + * Register the permission invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/ui/permission/tests/invariant.spec.ts b/packages/ui/permission/tests/invariant.spec.ts new file mode 100644 index 0000000000..6488239d38 --- /dev/null +++ b/packages/ui/permission/tests/invariant.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +class PermissionProbe extends Service { + readonly names = ['safe', 'trusted'] + + constructor(ctx: Context) { + super(ctx, 'permission') + } +} + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(PermissionProbe) + await ctx.plugin(InvariantService) + await ctx.plugin(PermissionInvariant) + return ctx +} + +function presetEvent(preset: string): SessionEvent { + return { type: 'permission/preset', seq: 0, time: 0, data: { preset } } +} + +describe('permission invariants', () => { + it('accepts configured preset events and ignores other session data', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow() + expect(() => { ctx.emit('session/event', {} as Session, { + type: 'turn/end', seq: 0, time: 0, data: {}, + } as SessionEvent) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects a durable preset that the active table cannot resolve', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) }) + .toThrow(/unknown preset "missing"/) + }) +}) diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 56bf12cda3..31c3e78e7d 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio`. @module @deepseek-ai/dsh-stdio/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-stdio`. + * @module @deepseek-ai/dsh-stdio/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' /** Cordis companion plugin name. */ export const name = 'stdio-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ui-stdio', - inject: [ - 'agents', - 'userInteraction', - ], - effects: [ - 'ctx.on("session/event")', - 'userInteraction.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index 2b18cad1f3..140bbd79c5 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-ask-user`. @module @deepseek-ai/dsh-tool-ask-user/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ask-user`. + * @module @deepseek-ai/dsh-tool-ask-user/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' /** Cordis companion plugin name. */ export const name = 'tool-ask-user-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-ask-user', - inject: [ - 'tools', - 'userInteraction', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index d3014146d7..f8072f3968 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tui`. @module @deepseek-ai/dsh-tui/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tui`. + * @module @deepseek-ai/dsh-tui/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tui' /** Cordis companion plugin name. */ export const name = 'tui-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ui-tui', - inject: [ - 'agents', - 'userInteraction', - 'tools', - ], - effects: [ - 'ctx.on("session/event")', - 'userInteraction.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 9a905be06f..9fc42fa908 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -1,31 +1,88 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-approval`. @module @deepseek-ai/dsh-user-approval/invariant */ +/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ApprovalRequestId } from './index.ts' +import { APPROVAL_POLICIES } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' +const APPROVAL_OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] as const /** Cordis companion plugin name. */ export const name = 'user-approval-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ApprovalService', - effects: [ - 'ctx.provide("approval")', - 'ctx.on("agent/pre-step")', - ], - services: [ - 'approval', - ], - }) +type ApprovalTransition = + | { kind: 'asked'; id: ApprovalRequestId } + | { kind: 'decided'; id: ApprovalRequestId } + +/** Validate one approval event against committed unmatched questions. */ +function validateApprovalEvent( + pending: ReadonlySet, + event: SessionEvent, + fail: InvariantFailure, +): ApprovalTransition | undefined { + if (event.type === 'approval/asked') { + if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty') + if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) + return { kind: 'asked', id: event.data.id } + } + if (event.type === 'approval/decided') { + if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) + if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) { + fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`) + } + return { kind: 'decided', id: event.data.id } + } + if (event.type === 'approval/policy' && !APPROVAL_POLICIES.includes(event.data.policy)) { + fail(`approval/policy carries unknown policy ${JSON.stringify(event.data.policy)}`) + } + return undefined } +/** Apply one accepted approval-pair transition. */ +function applyApprovalTransition(pending: Set, transition: ApprovalTransition): void { + if (transition.kind === 'asked') pending.add(transition.id) + else pending.delete(transition.id) +} + +/** Install audit pairing and closed-vocabulary checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap>() + const staged = new WeakMap() + const seed = (session: Session): Set => { + const pending = new Set() + traces.set(session, pending) + for (const event of session.events) { + const transition = validateApprovalEvent(pending, event, fail) + if (transition !== undefined) applyApprovalTransition(pending, transition) + } + return pending + } + const traceFor = (session: Session): Set => 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) => { + if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every package-owned pair event */ + if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation') + staged.delete(event) + applyApprovalTransition(traceFor(session), candidate.transition) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateApprovalEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the approval invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts new file mode 100644 index 0000000000..ea9d4472e5 --- /dev/null +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(ApprovalInvariant) + return ctx +} + +describe('approval invariants', () => { + it('accepts paired audit events and closed policy values', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const id = ApprovalRequestId('ask-1') + session.append('approval/asked', { id, toolName: 'bash' }) + session.append('approval/decided', { id, outcome: 'allowed-once' }) + session.append('approval/policy', { policy: 'never' }) + }) + + it('rebuilds an unmatched question from an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const id = ApprovalRequestId('ask-resume') + session.append('approval/asked', { id, toolName: 'bash' }) + await ctx.plugin(InvariantService) + await ctx.plugin(ApprovalInvariant) + expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('adopts a bare session first observed through publication', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-approval-session')) + const id = ApprovalRequestId('bare-ask') + const asked = { + type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' }, + } as const + const decided = { + type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const }, + } as const + expect(() => { + ctx.emit('session/event', session, asked) + ctx.emit('session/event', session, decided) + }).not.toThrow() + }) + + it('rejects malformed and unpaired audit events', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const id = ApprovalRequestId('ask-1') + expect(() => session.append('approval/asked', { id, toolName: '' })) + .toThrow(/toolName must be non-empty/) + session.append('approval/asked', { id, toolName: 'bash' }) + expect(() => session.append('approval/asked', { id, toolName: 'bash' })) + .toThrow(/repeated open id/) + expect(() => session.append('approval/decided', { + id: ApprovalRequestId('missing'), outcome: 'rejected', + })).toThrow(/no matching approval\/asked/) + expect(() => session.append('approval/decided', { id, outcome: 'maybe' as never })) + .toThrow(/unknown outcome/) + expect(() => session.append('approval/policy', { policy: 'always' as never })) + .toThrow(/unknown policy/) + }) +}) diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 85029b692b..f4f2f2f31e 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-interaction`. @module @deepseek-ai/dsh-user-interaction/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-user-interaction`. + * @module @deepseek-ai/dsh-user-interaction/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' /** Cordis companion plugin name. */ export const name = 'user-interaction-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'UserInteractionService', - effects: [ - 'ctx.provide("userInteraction")', - ], - services: [ - 'userInteraction', - ], - }) -} +/** + * No runtime invariant: the single provider slot is validated at registration and asks return + * directly to their caller; the seam publishes no independent request/answer audit stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index d632921d35..bf29a81b4c 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -1,22 +1,24 @@ -/** Package-owned runtime contract for @deepseek-ai/dsh-brand. @module @deepseek-ai/dsh-brand/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-brand`. + * @module @deepseek-ai/dsh-brand/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' /** Cordis companion plugin name. */ export const name = 'brand-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that the nominal-type primitive remains erased at runtime. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const brandRuntime = await import('./index.ts') - assertInvariant(fail, Object.keys(brandRuntime).length === 0, - 'the branded-id primitive must remain type-only with no runtime exports') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index fc7c9c3129..dbd8e38f51 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-home. @module @deepseek-ai/dsh-home/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-home`. + * @module @deepseek-ai/dsh-home/invariant + */ /* jscpd:ignore-start */ -import { resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-home' /** Cordis companion plugin name. */ export const name = 'home-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the canonical environment key and configured-path precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') - const environmentKey: string = DSH_HOME_ENV - assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), - 'the canonical Harness home environment key must remain DSH_HOME') - const configured = 'relative-invariant-home' - assertInvariant(fail, resolveDshHome(configured) === resolve(configured), - 'an explicitly configured Harness home must normalize to an absolute path') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index 20a3962ff4..f1661b7f52 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-paths. @module @deepseek-ai/dsh-paths/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-paths`. + * @module @deepseek-ai/dsh-paths/invariant + */ /* jscpd:ignore-start */ -import { homedir } from 'node:os' -import { join, resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' /** Cordis companion plugin name. */ export const name = 'paths-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert tilde expansion and explicit-over-environment home precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') - assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), - 'supported tilde prefixes must expand against the operating-system home') - const configured = 'relative-invariant-home' - const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) - assertInvariant(fail, resolved === resolve(configured), - 'an explicit DSH home must override the environment and normalize to an absolute path') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index fa8ab7d36a..0365793b03 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -1,33 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-retention. @module @deepseek-ai/dsh-retention/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-retention`. + * @module @deepseek-ai/dsh-retention/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' /** Cordis companion plugin name. */ export const name = 'retention-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert exact head-retention accounting after the budget is exceeded. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { ItemRetainer } = await import('./index.ts') - const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) - retainer.push('first') - retainer.push('second') - retainer.push('third') - const result = retainer.finish() - assertInvariant(fail, - result.items.join(',') === 'first,second' - && result.seen === 3 - && result.kept === 2 - && result.truncated - && result.omitted.kind === 'exact' - && result.omitted.count === 1, - 'head retention must keep the prefix and report exact seen, kept, and omitted counts') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 2d9d22f5d6..bb9604d6b7 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-timeout. @module @deepseek-ai/dsh-timeout/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-timeout`. + * @module @deepseek-ai/dsh-timeout/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' /** Cordis companion plugin name. */ export const name = 'timeout-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert default-before-cap arithmetic and capability-code classification. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') - assertInvariant(fail, - clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, - 'timeout resolution must apply the default before capping and preserve smaller requests') - const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) - assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, - 'timeout classification must recover a matching capability-owned reason') - assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, - 'timeout classification must reject a reason owned by another capability') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 1cceb0f166..435f9ca549 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-web`. @module @deepseek-ai/dsh-tool-web/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-web`. + * @module @deepseek-ai/dsh-tool-web/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' /** Cordis companion plugin name. */ export const name = 'tool-web-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-web', - inject: [ - 'tools', - 'web', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index 5a29395715..053fb12200 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-fetch-local`. @module @deepseek-ai/dsh-web-fetch-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-fetch-local`. + * @module @deepseek-ai/dsh-web-fetch-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' /** Cordis companion plugin name. */ export const name = 'web-fetch-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-fetch-local', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index bfed9260dc..8b79315f7d 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -1,30 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-deepseek`. + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-deepseek`. * @module @deepseek-ai/dsh-web-search-deepseek/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' /** Cordis companion plugin name. */ export const name = 'web-search-deepseek-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-deepseek', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index 8d5da8d433..060ceb78ba 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-exa`. @module @deepseek-ai/dsh-web-search-exa/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-exa`. + * @module @deepseek-ai/dsh-web-search-exa/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' /** Cordis companion plugin name. */ export const name = 'web-search-exa-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-exa', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index 7669f6e53a..cf3e009fed 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -1,30 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-perplexity`. + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-perplexity`. * @module @deepseek-ai/dsh-web-search-perplexity/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' /** Cordis companion plugin name. */ export const name = 'web-search-perplexity-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-perplexity', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index 395679cf36..2ac094b34f 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web`. @module @deepseek-ai/dsh-web/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web`. + * @module @deepseek-ai/dsh-web/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' /** Cordis companion plugin name. */ export const name = 'web-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WebService', - effects: [ - 'ctx.provide("web")', - ], - services: [ - 'web', - ], - }) -} +/** + * No runtime invariant: provider maps are private and selection/result caps are enforced on each + * call; the seam publishes no independent registry or request/result observation stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 966e4b3613..5f3ebc68ce 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-workflow`. @module @deepseek-ai/dsh-tool-workflow/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`. + * @module @deepseek-ai/dsh-tool-workflow/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' /** Cordis companion plugin name. */ export const name = 'tool-workflow-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-workflow', - inject: [ - 'tools', - 'workflows', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 27c0865d79..6845aeebff 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow-workerthread`. + * Package-owned invariant companion for `@deepseek-ai/dsh-workflow-workerthread`. * @module @deepseek-ai/dsh-workflow-workerthread/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' /** Cordis companion plugin name. */ export const name = 'workflow-workerthread-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WorkerWorkflowEngine', - inject: [ - 'subagents', - ], - effects: [ - 'ctx.provide("workflows")', - ], - services: [ - 'workflows', - ], - }) -} +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * worker protocol and built-worker tests cover it. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index a37d480e69..f6b8b8ced2 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,24 +1,134 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow`. @module @deepseek-ai/dsh-workflow/invariant */ +/** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowResultInfo, + WorkflowRunInfo, +} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' /** Cordis companion plugin name. */ export const name = 'workflow-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +interface WorkflowTrace { + meta: string + agents: Map + starts: number +} + +/** Require every event for a run to retain its validated identity snapshot. */ +function traceFor( + traces: ReadonlyMap, + info: WorkflowRunInfo, + fail: InvariantFailure, +): WorkflowTrace { + const trace = traces.get(info.id) + if (trace === undefined) fail(`workflow event has no matching workflow/start for run ${JSON.stringify(info.id)}`) + if (trace.meta !== JSON.stringify(info.meta)) { + fail(`workflow event meta diverges from workflow/start for run ${JSON.stringify(info.id)}`) + } + return trace +} + +/** Assert the immutable identity fields shared by an agent pair. */ +function validateAgentEnd(start: WorkflowAgentInfo, end: WorkflowAgentEndInfo, fail: InvariantFailure): void { + if (start.label !== end.label || start.phase !== end.phase || start.childId !== end.childId) { + fail(`workflow/agent-end identity diverges from workflow/agent-start for seq ${end.seq}`) + } + const outcome: string = end.outcome + if (outcome !== 'completed' && outcome !== 'failed' && outcome !== 'cancelled') { + fail(`workflow/agent-end carries unknown outcome ${JSON.stringify(outcome)}`) + } +} + +/** Validate a terminal result against the accumulated run trace. */ +function validateWorkflowEnd(trace: WorkflowTrace, result: WorkflowResultInfo, fail: InvariantFailure): void { + if (trace.agents.size > 0) fail(`workflow/end has ${trace.agents.size} agent call(s) without workflow/agent-end`) + if (!Number.isSafeInteger(result.agentsStarted) || result.agentsStarted < trace.starts) { + fail('workflow/end agentsStarted must be a safe integer covering every observed agent start') + } + if (result.stopReason === 'completed' ? result.error !== undefined : typeof result.error !== 'string') { + fail('workflow/end error must be absent exactly for completed runs') + } +} + +/** Install workflow start/end and child-call pairing checks. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'workflows', value => serviceShapeViolation(value, { - methods: ['start'], - })) + const traces = new Map() + const stagedStarts = new WeakSet() + const stagedAgentStarts = new WeakSet() + const stagedAgentEnds = new WeakSet() + const stagedEnds = new WeakSet() + + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'workflow/start') { + const info = args[0] as WorkflowRunInfo + if (String(info.id).length === 0 || info.meta.name.length === 0 || info.meta.description.length === 0) { + fail('workflow/start id, meta.name, and meta.description must be non-empty') + } + if (traces.has(info.id)) fail(`workflow/start repeated run id ${JSON.stringify(info.id)}`) + stagedStarts.add(info) + return + } + if (!eventName.startsWith('workflow/')) return + const info = args[0] as WorkflowRunInfo + const trace = traceFor(traces, info, fail) + if (eventName === 'workflow/agent-start') { + const agent = args[1] as WorkflowAgentInfo + if (!Number.isSafeInteger(agent.seq) || agent.seq < 1 || String(agent.childId).length === 0) { + fail('workflow/agent-start seq must be positive and childId must be non-empty') + } + if (trace.agents.has(agent.seq)) fail(`workflow/agent-start repeated seq ${agent.seq}`) + stagedAgentStarts.add(agent) + return + } + if (eventName === 'workflow/agent-end') { + const agent = args[1] as WorkflowAgentEndInfo + const start = trace.agents.get(agent.seq) + if (start === undefined) return fail(`workflow/agent-end has no matching start for seq ${agent.seq}`) + validateAgentEnd(start, agent, fail) + stagedAgentEnds.add(agent) + return + } + if (eventName === 'workflow/end') { + const result = args[1] as WorkflowResultInfo + validateWorkflowEnd(trace, result, fail) + stagedEnds.add(result) + } + }, { global: true }) + + ctx.on('workflow/start', (info) => { + /* v8 ignore next -- internal/dispatch stages the same run-info object */ + if (!stagedStarts.delete(info)) return + traces.set(info.id, { meta: JSON.stringify(info.meta), agents: new Map(), starts: 0 }) + }, { global: true }) + ctx.on('workflow/agent-start', (info, agent) => { + /* v8 ignore next -- internal/dispatch stages the same agent object */ + if (!stagedAgentStarts.delete(agent)) return + const trace = traceFor(traces, info, fail) + trace.agents.set(agent.seq, agent) + trace.starts += 1 + }, { global: true }) + ctx.on('workflow/agent-end', (info, agent) => { + /* v8 ignore next -- internal/dispatch stages the same agent object */ + if (!stagedAgentEnds.delete(agent)) return + traceFor(traces, info, fail).agents.delete(agent.seq) + }, { global: true }) + ctx.on('workflow/end', (info, result) => { + /* v8 ignore next -- internal/dispatch stages the same result object */ + if (!stagedEnds.delete(result)) return + traces.delete(info.id) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the workflow invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/workflow/workflow/tests/invariant.spec.ts b/packages/workflow/workflow/tests/invariant.spec.ts new file mode 100644 index 0000000000..671a7a3e86 --- /dev/null +++ b/packages/workflow/workflow/tests/invariant.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import { WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowResultInfo, + WorkflowRunInfo, +} from '@deepseek-ai/dsh-workflow' +import * as WorkflowInvariant from '@deepseek-ai/dsh-workflow/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(WorkflowInvariant) + return ctx +} + +const info = (overrides: Partial = {}): WorkflowRunInfo => ({ + id: WorkflowRunId('workflow-1'), + meta: { name: 'review', description: 'Review a change' }, + ...overrides, +}) + +const agent = (overrides: Partial = {}): WorkflowAgentInfo => ({ + seq: 1, + label: 'reviewer', + childId: SessionId('child-1'), + ...overrides, +}) + +const agentEnd = (overrides: Partial = {}): WorkflowAgentEndInfo => ({ + ...agent(), + outcome: 'completed', + ...overrides, +}) + +const result = (overrides: Partial = {}): WorkflowResultInfo => ({ + stopReason: 'completed', + agentsStarted: 1, + ...overrides, +}) + +describe('workflow invariants', () => { + it('accepts a complete workflow and child lifecycle', async () => { + const ctx = await setup() + const run = info() + ctx.emit('workflow/start', run) + ctx.emit('workflow/phase', run, 'inspect') + ctx.emit('workflow/log', run, 'working') + ctx.emit('workflow/agent-start', run, agent()) + ctx.emit('workflow/agent-end', run, agentEnd()) + ctx.emit('workflow/end', run, result()) + ctx.emit('tools/change') + }) + + it('rejects invalid run identity and enclosure', async () => { + const ctx = await setup() + expect(() => { ctx.emit('workflow/start', info({ id: WorkflowRunId('') })) }).toThrow(/must be non-empty/) + const run = info() + ctx.emit('workflow/start', run) + expect(() => { ctx.emit('workflow/start', run) }).toThrow(/repeated run id/) + expect(() => { ctx.emit('workflow/log', info({ meta: { name: 'other', description: 'x' } }), 'x') }) + .toThrow(/meta diverges/) + const fresh = await setup() + expect(() => { fresh.emit('workflow/log', info(), 'x') }).toThrow(/no matching workflow\/start/) + }) + + it('rejects malformed and unpaired child lifecycles', async () => { + const ctx = await setup() + const run = info() + ctx.emit('workflow/start', run) + expect(() => { ctx.emit('workflow/agent-start', run, agent({ seq: 0 })) }).toThrow(/seq must be positive/) + ctx.emit('workflow/agent-start', run, agent()) + expect(() => { ctx.emit('workflow/agent-start', run, agent()) }).toThrow(/repeated seq/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ seq: 2 })) }).toThrow(/no matching start/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ childId: SessionId('other') })) }) + .toThrow(/identity diverges/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ outcome: 'unknown' as never })) }) + .toThrow(/unknown outcome/) + }) + + it('rejects inconsistent terminal results', async () => { + const active = await setup() + active.emit('workflow/start', info()) + active.emit('workflow/agent-start', info(), agent()) + expect(() => { active.emit('workflow/end', info(), result()) }).toThrow(/without workflow\/agent-end/) + + const count = await setup() + count.emit('workflow/start', info()) + count.emit('workflow/agent-start', info(), agent()) + count.emit('workflow/agent-end', info(), agentEnd()) + expect(() => { count.emit('workflow/end', info(), result({ agentsStarted: 0 })) }) + .toThrow(/covering every observed agent start/) + + const completed = await setup() + completed.emit('workflow/start', info()) + expect(() => { completed.emit('workflow/end', info(), result({ error: 'unexpected' })) }) + .toThrow(/absent exactly for completed/) + + const failed = await setup() + failed.emit('workflow/start', info()) + expect(() => { failed.emit('workflow/end', info(), result({ stopReason: 'error' })) }) + .toThrow(/absent exactly for completed/) + }) +}) diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index 0df3824071..01413ab75f 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -58,8 +58,11 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/log', (info, message) => { seen.push([info, message]) }) ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) engine.emit('workflow/log', INFO, 'hello') engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' }) + engine.emit('workflow/agent-end', INFO, { seq: 1, label: 'l', childId: 'c', outcome: 'completed' }) + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 }) expect(seen).toEqual([ [INFO, 'hello'], [INFO, { seq: 1, label: 'l', childId: 'c' }], @@ -77,8 +80,11 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/agent-start', (_info, agent) => { seen.push(agent.label) }) const engine = ctx.workflows as StubEngine const payload = { seq: 1, label: 'original', childId: 'c' } + engine.emit('workflow/start', INFO) engine.emit('workflow/agent-start', INFO, payload) await Promise.resolve() + engine.emit('workflow/agent-end', INFO, { ...payload, outcome: 'completed' }) + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 }) expect(seen).toEqual(['original']) expect(String(warn.mock.calls[0]![0])).toContain('listener rejected') }) @@ -91,7 +97,9 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/phase', () => { throw new Error('bad listener') }) ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 }) expect(reached).toEqual(['Scan']) expect(warn).toHaveBeenCalledOnce() expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw') @@ -107,7 +115,9 @@ describe('dsh-workflow (interface)', () => { }) ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 }) expect(reached).toEqual(['Scan']) expect(warn).toHaveBeenCalledOnce() expect(String(warn.mock.calls[0]![0])).toContain('[unrenderable thrown value]') diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 9995d078bf..1014ce5033 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -16,8 +16,10 @@ function handwrittenInvariant(packageName: string): string { return ` export const name = 'probe-invariant' export const inject = ['invariants'] -const install = (_ctx: unknown, fail: (message: string) => never) => { - if (typeof ${JSON.stringify(packageName)} !== 'string') fail('package name must remain a string') +const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => { + ctx.on('probe/value', (value) => { + if (value < 0) fail('observed values must be non-negative') + }) } export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) @@ -65,41 +67,6 @@ function fixture(options: { return root } -function addConformingPackage(root: string, slug: string, packageName: string, source: string): void { - const dir = join(root, `packages/core/${slug}`) - mkdirSync(join(dir, 'src'), { recursive: true }) - writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ - name: packageName, - exports: { - './invariant': { - types: './lib/types/invariant.d.ts', - default: './lib/invariant.js', - }, - }, - files: ['lib/invariant.js'], - peerDependencies: { '@deepseek-ai/dsh-invariants': '^0.0.1' }, - devDependencies: { '@deepseek-ai/dsh-invariants': 'workspace:^' }, - }, null, 2)}\n`) - writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ - references: [{ path: '../../support/invariants' }], - }, null, 2)}\n`) - writeFileSync(join(dir, 'src/invariant.ts'), source) - writeFileSync(join(dir, 'tsdown.config.ts'), "export default { entry: ['lib/types/invariant.js'] }\n") -} - -function nameObservedInvariant(packageName: string, pluginName: string): string { - return ` -import { observePluginInvariant } from '@deepseek-ai/dsh-invariants' -export const name = 'probe-invariant' -export const inject = ['invariants'] -const install = (ctx: never, fail: (message: string) => never) => { - observePluginInvariant(ctx, fail, { name: ${JSON.stringify(pluginName)} }) -} -export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => - Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) -` -} - describe('package invariant gate', () => { it('accepts a hand-owned checking companion with publication metadata', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) @@ -139,27 +106,24 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo ])) }) - it('rejects generated markers and empty or reporter-free installers', () => { + it('rejects generated markers and reporter-free executable installers', () => { const generated = fixture({ source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`, }) expect(collectPackageInvariantViolations(generated).map(violation => violation.message)) .toContain('invariant companions must be hand-owned and may not carry @generated markers') - const empty = fixture({ + const reporterFree = fixture({ source: ` export const name = 'probe-invariant' export const inject = ['invariants'] -const install = () => {} +const install = () => { void 0 } export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) `, }) - expect(collectPackageInvariantViolations(empty).map(violation => violation.message)) - .toEqual(expect.arrayContaining([ - 'install function must contain a package-owned invariant check', - 'install function must accept the bound failure reporter as its second parameter', - ])) + expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message)) + .toContain('install function must accept the bound failure reporter as its second parameter') const unused = fixture({ source: ` @@ -174,22 +138,19 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo .toContain('install function must use its bound failure reporter') }) - it('rejects duplicate name-based plugin observers across packages', () => { - const root = fixture({ - source: nameObservedInvariant('@deepseek-ai/dsh-probe', 'shared-runtime-name'), - }) - addConformingPackage( - root, - 'probe-two', - '@deepseek-ai/dsh-probe-two', - nameObservedInvariant('@deepseek-ai/dsh-probe-two', 'shared-runtime-name'), - ) - expect(collectPackageInvariantViolations(root).map(violation => violation.message)) - .toContain('name-based plugin invariant "shared-runtime-name" is already owned by "@deepseek-ai/dsh-probe-two"') - }) + it('accepts explained empty installers and rejects unexplained ones', () => { + const explained = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const PACKAGE_NAME = '@deepseek-ai/dsh-probe' +/** No runtime invariant: this pure package owns no events or mutable data. */ +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register(PACKAGE_NAME, install) +` + expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([]) - it('rejects an unexplained empty package installer', () => { - const source = ` + const unexplained = ` export const name = 'probe-invariant' export const inject = ['invariants'] const PACKAGE_NAME = '@deepseek-ai/dsh-probe' @@ -197,7 +158,7 @@ const install = () => {} export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => ctx.invariants.register(PACKAGE_NAME, install) ` - expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message)) .toContain('empty install function must explain why with a "No runtime invariant:" comment') }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 6448888680..4600d04ee3 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -8,6 +8,9 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' +/** Required explanation marker for an intentionally empty installer. */ +export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' + interface PackageManifest { name?: string exports?: Record @@ -53,23 +56,11 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { /** Return all violations of the package-invariant companion contract. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] - const observedPluginNames = new Map() for (const owner of packageInvariantOwners(root)) { const manifest = readManifest(resolve(root, owner.manifestPath)) checkManifest(owner, manifest, violations) checkBuild(owner, root, violations) - for (const pluginName of checkSource(owner, root, violations)) { - const existing = observedPluginNames.get(pluginName) - if (existing === undefined) { - observedPluginNames.set(pluginName, owner) - } else { - addViolation( - violations, - owner.sourcePath, - `name-based plugin invariant ${JSON.stringify(pluginName)} is already owned by ${JSON.stringify(existing.packageName)}`, - ) - } - } + checkSource(owner, root, violations) } return violations } @@ -151,11 +142,11 @@ function checkSource( owner: PackageInvariantOwner, root: string, violations: PackageInvariantViolation[], -): string[] { +): void { const absolutePath = resolve(root, owner.sourcePath) if (!existsSync(absolutePath)) { addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') - return [] + return } const sourceText = readFileSync(absolutePath, 'utf8') if (sourceText.includes('@generated')) { @@ -206,49 +197,26 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } - checkInstaller(owner, sourceFile, violations) - return nameOnlyObservedPlugins(sourceFile) -} - -function nameOnlyObservedPlugins(sourceFile: ts.SourceFile): string[] { - const names: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node) - && ts.isIdentifier(node.expression) - && node.expression.text === 'observePluginInvariant') { - const contract = node.arguments[2] - if (contract !== undefined && ts.isObjectLiteralExpression(contract)) { - let hasExactPlugin = false - let name: string | undefined - for (const property of contract.properties) { - if (!ts.isPropertyAssignment(property)) continue - const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) - ? property.name.text - : undefined - if (key === 'plugin') hasExactPlugin = true - if (key === 'name') name = stringValue(property.initializer, new Map()) - } - if (!hasExactPlugin && name !== undefined) names.push(name) - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return names + checkInstaller(owner, sourceFile, sourceText, violations) } function checkInstaller( owner: PackageInvariantOwner, sourceFile: ts.SourceFile, + sourceText: string, violations: PackageInvariantViolation[], ): void { let initializer: ts.Expression | undefined + let declarationStatement: ts.VariableStatement | undefined for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (ts.isIdentifier(declaration.name) && declaration.name.text === 'install' - && declaration.initializer !== undefined) initializer = declaration.initializer + && declaration.initializer !== undefined) { + initializer = declaration.initializer + declarationStatement = statement + } } } const installer = initializer === undefined ? undefined : installerFunction(initializer) @@ -257,7 +225,17 @@ function checkInstaller( return } if (ts.isBlock(installer.body) && installer.body.statements.length === 0) { - addViolation(violations, owner.sourcePath, 'install function must contain a package-owned invariant check') + const declarationText = declarationStatement === undefined + ? '' + : sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd()) + if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) { + addViolation( + violations, + owner.sourcePath, + `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`, + ) + } + return } const reporter = installer.parameters[1]?.name if (reporter === undefined || !ts.isIdentifier(reporter)) { diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 5c3b9120bc..56837e92fb 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -72,10 +72,24 @@ describe('global test invariant host', () => { it('limits manual composition to focused invariant topology tests', () => { expect(MANUAL_INVARIANT_TESTS).toEqual([ '/packages/support/invariants/tests/service.spec.ts', + '/packages/bash/bash/tests/invariant.spec.ts', + '/packages/compact/compact/tests/invariant.spec.ts', + '/packages/context/time-context/tests/invariant.spec.ts', '/packages/core/session/tests/invariant.spec.ts', '/packages/core/agent/tests/invariant.spec.ts', '/packages/core/scope/tests/invariant.spec.ts', '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/core/system-prompt/tests/invariant.spec.ts', + '/packages/core/tools/tests/invariant.spec.ts', + '/packages/fs/fs/tests/invariant.spec.ts', + '/packages/hooks/hook-protocol/tests/invariant.spec.ts', + '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/subagent/subagent/tests/invariant.spec.ts', + '/packages/tasks/tasks/tests/invariant.spec.ts', + '/packages/todo/tool-todo/tests/invariant.spec.ts', + '/packages/ui/permission/tests/invariant.spec.ts', + '/packages/ui/user-approval/tests/invariant.spec.ts', + '/packages/workflow/workflow/tests/invariant.spec.ts', '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', ]) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index b91fdda462..1c8c1302d6 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -31,10 +31,24 @@ export const testInvariantCompanions: Readonly to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit From b8ad62eb359409dd688ef3c2401fd6af27d2bf7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:04:40 +0800 Subject: [PATCH 27/48] fix(invariants): finish current-master ownership migration --- docs/cordis-catalog/services.md | 2 +- packages/AGENTS.md | 4 ++-- packages/compact/compact/src/invariant.ts | 3 +++ packages/hooks/hook-protocol/src/invariant.ts | 3 +++ packages/spill/spill-policy/tests/spill-policy.spec.ts | 10 ---------- packages/support/invariants/README.md | 2 +- packages/ui/user-approval/src/invariant.ts | 3 +++ scripts/package-invariants.ts | 2 +- scripts/test-invariants.spec.ts | 2 +- scripts/test-invariants.ts | 2 +- 10 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d494c5827f..4b0e132da8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -501,7 +501,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:388`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 16ee490395..77a4712e04 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,8 +15,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. -- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an explicit invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relationships; when none exists, keep an empty installer with a package-specific `No runtime invariant:` explanation instead of inventing an API-shape assertion. Generated companions, unexplained empties, and non-empty installers that ignore the reporter fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). +- **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. +- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). Naming notes: diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 59fdc806b2..da5d5eba5a 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -66,6 +66,8 @@ function applyCompactionTransition( } /** 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() const staged = new WeakMap() @@ -98,6 +100,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the compact invariant companion. diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 73d6f08e3b..6972d109da 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -57,6 +57,8 @@ function applyHookTransition(pending: Map, transition: HookTrans } /** Install hook invoked/result pairing 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>() const staged = new WeakMap() @@ -88,6 +90,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the hook-protocol invariant companion. diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index f0cbbbd2a0..d249430ab1 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -110,16 +110,6 @@ describe('config validation', () => { await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) }) - it('rejects a configured policy that omits its post-execute listener', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await expect(ctx.plugin({ - name: 'spill-policy', - inject: ['tools'], - apply(_child: Context, _config: { maxInlineBytes?: number }) {}, - }, { maxInlineBytes: 10 })).rejects.toThrow(/listener must exist exactly when maxInlineBytes is configured/) - }) }) describe('oversized plain-text replacement', () => { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 58cf2e8f2a..4cd2aa1f9b 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -69,7 +69,7 @@ Every ordinary Vitest topology mounts an explicitly enabled service and the curr ## Model Experience -None. The service and companions observe runtime events, mutable snapshots, and requests but never alter prompts, messages, schemas, streams, or tool results. +None, as the service and companions observe runtime events and mutable snapshots without altering prompts, messages, schemas, streams, or tool results. #### KV Cache effect diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 9fc42fa908..5643e412c5 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -49,6 +49,8 @@ function applyApprovalTransition(pending: Set, transition: Ap } /** Install audit pairing and closed-vocabulary 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>() const staged = new WeakMap() @@ -80,6 +82,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the approval invariant companion. diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 4600d04ee3..6b099c084d 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -9,7 +9,7 @@ import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' /** Required explanation marker for an intentionally empty installer. */ -export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' +const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' interface PackageManifest { name?: string diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 56837e92fb..52182cafaf 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -72,7 +72,6 @@ describe('global test invariant host', () => { it('limits manual composition to focused invariant topology tests', () => { expect(MANUAL_INVARIANT_TESTS).toEqual([ '/packages/support/invariants/tests/service.spec.ts', - '/packages/bash/bash/tests/invariant.spec.ts', '/packages/compact/compact/tests/invariant.spec.ts', '/packages/context/time-context/tests/invariant.spec.ts', '/packages/core/session/tests/invariant.spec.ts', @@ -84,6 +83,7 @@ describe('global test invariant host', () => { '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', '/packages/todo/tool-todo/tests/invariant.spec.ts', diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 1c8c1302d6..2f5eec6271 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -31,7 +31,6 @@ export const testInvariantCompanions: Readonly Date: Mon, 20 Jul 2026 20:09:48 +0800 Subject: [PATCH 28/48] test(session): preserve surface boundary coverage --- packages/core/session/tests/surface.spec.ts | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fc8ebfcd10..5243564b6c 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } as unknown as SessionEvent } +function toolResultEvent( + seq: number, + callId: string, + surfaceOp: SurfaceEvent['surfaceOp'] = 'append', + sourceEventSeqs?: number[], +): SessionEvent { + return { + type: 'tool/result', + seq, + time: seq, + data: { + turn: 1, + step: 1, + callId: CallId(callId), + content: [{ type: 'text', text: `result ${seq}` }], + isError: false, + }, + surfaceOp, + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + } +} + describe('foldSurface provenance', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { const events = [ @@ -94,6 +116,33 @@ describe('foldSurface provenance', () => { ) }) +describe('foldSurface tool-result rewrites', () => { + it('rejects a replacement spanning multiple current nodes', () => { + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), + ] + expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/) + }) + + it('rejects a replacement targeting a non-result node', () => { + const events = [ + provenanceEvent(0, undefined), + toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/) + }) + + it('rejects changes outside tool-result content', () => { + const events = [ + toolResultEvent(0, 'original'), + toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/may change only content/) + }) +}) + describe('SurfaceManager', () => { it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) From d897adac87d0adf2f38d580466c079f1329dcc64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:16:22 +0800 Subject: [PATCH 29/48] fix(invariants): keep registration gate final-head clean --- packages/AGENTS.md | 4 ++-- scripts/package-invariants.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index f1403bb973..43993b9a63 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,8 +15,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. -- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relations; when none exists, keep an empty installer with a `No runtime invariant:` comment explaining why instead of inventing a shape or presence assertion. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Registry contributions prove disposal** through an HMR test: dispose the contributing fiber and observe removal ([testing policy](../docs/testing.md)). +- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give an empty installer a package-specific `No runtime invariant:` reason. `verify-package-invariants` gates the contract ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 0078a6a4c4..a635a4e583 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -12,7 +12,7 @@ import ts from 'typescript' export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' /** Required explanation marker for an intentionally empty installer. */ -export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' +const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' interface PackageManifest { name?: string From 0405a211c84a8a78b7bbded5cb180012b9952ceb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:09:48 +0800 Subject: [PATCH 30/48] test(session): preserve surface boundary coverage --- packages/core/session/tests/surface.spec.ts | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fc8ebfcd10..5243564b6c 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } as unknown as SessionEvent } +function toolResultEvent( + seq: number, + callId: string, + surfaceOp: SurfaceEvent['surfaceOp'] = 'append', + sourceEventSeqs?: number[], +): SessionEvent { + return { + type: 'tool/result', + seq, + time: seq, + data: { + turn: 1, + step: 1, + callId: CallId(callId), + content: [{ type: 'text', text: `result ${seq}` }], + isError: false, + }, + surfaceOp, + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + } +} + describe('foldSurface provenance', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { const events = [ @@ -94,6 +116,33 @@ describe('foldSurface provenance', () => { ) }) +describe('foldSurface tool-result rewrites', () => { + it('rejects a replacement spanning multiple current nodes', () => { + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), + ] + expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/) + }) + + it('rejects a replacement targeting a non-result node', () => { + const events = [ + provenanceEvent(0, undefined), + toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/) + }) + + it('rejects changes outside tool-result content', () => { + const events = [ + toolResultEvent(0, 'original'), + toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/may change only content/) + }) +}) + describe('SurfaceManager', () => { it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) From c25e89cb4ce3990a2ae0bb146c45a85be51cc4f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:23:57 +0800 Subject: [PATCH 31/48] fix(invariants): reconcile TUI package rename --- packages/examples/tui-demo/package.json | 1 + packages/ui/stdio/src/invariant.ts | 30 ------------------------- pnpm-lock.yaml | 3 +++ 3 files changed, 4 insertions(+), 30 deletions(-) delete mode 100644 packages/ui/stdio/src/invariant.ts diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index c356105964..841505fb00 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts deleted file mode 100644 index 37176f11c6..0000000000 --- a/packages/ui/stdio/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' - -/** Cordis companion plugin name. */ -export const name = 'stdio-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's 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)) -/* jscpd:ignore-end */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9067fc39b4..5bd0959770 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -930,6 +930,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../ui/tool-ask-user From 2c075711eb2e762cdd5ae1201b0a17d79b5e1ab8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 22:44:01 +0800 Subject: [PATCH 32/48] feat(tui): add session controls and compact views --- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 8 +- ...dedicated-full-screen-tui-front-door.zh.md | 8 +- docs/config-catalog.md | 20 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../fs-escalation-approved/session.jsonl | 4 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- examples/cordis-agent/composition.md | 3 + examples/cordis-agent/cordis.yml | 3 + examples/tui-agent/README.md | 2 +- examples/tui-agent/code-mode.cordis.yml | 2 +- examples/tui-agent/cordis.yml | 2 +- .../tests/fixtures/tui-scripted-llm.ts | 14 +- .../tests/fixtures/tui-scripted.cordis.yml | 4 + .../bash-terminal-card/terminal.expected.txt | 6 +- .../snapshots/code-mode/terminal.expected.txt | 6 +- .../terminal.expected.txt | 6 +- .../dynamic-workflow/terminal.expected.txt | 6 +- .../terminal.expected.txt | 6 +- .../parallel-file-reads/terminal.expected.txt | 6 +- .../snapshots/todo-plan/terminal.expected.txt | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 6 +- examples/tui-agent/tests/tui.snapshot.ts | 2 + packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 1 + packages/core/agent/src/llm-target.ts | 66 +++ packages/core/agent/tests/llm-target.spec.ts | 45 ++ packages/ui/acp/src/index.ts | 49 +- packages/ui/tui/README.md | 39 +- packages/ui/tui/package.json | 3 + packages/ui/tui/src/index.ts | 431 +++++++++++++++--- packages/ui/tui/tests/harness.ts | 41 +- packages/ui/tui/tests/plugin-shape.spec.ts | 2 +- .../advanced-cards-collapsed.expected.txt | 19 +- .../advanced-cards-expanded.expected.txt | 4 +- .../snapshots/code-mode-pending.expected.txt | 4 +- .../conversation-streaming.expected.txt | 12 +- .../cordis-tools-pending.expected.txt | 4 +- .../snapshots/disposed-terminal.expected.txt | 10 +- .../dynamic-workflow-pending.expected.txt | 7 +- .../snapshots/errors-and-help.expected.txt | 10 +- .../snapshots/model-selector.expected.txt | 52 +++ .../snapshots/model-switching.expected.txt | 35 ++ .../question-dialog-validation.expected.txt | 76 ++- .../snapshots/question-dialog.expected.txt | 68 +-- ...rface-after-compaction-narrow.expected.txt | 5 +- ...surface-after-compaction-wide.expected.txt | 4 +- .../surface-before-compaction.expected.txt | 9 +- .../snapshots/untrusted-controls.expected.txt | 63 +-- packages/ui/tui/tests/tui.snapshot.ts | 49 +- packages/ui/tui/tests/tui.spec.ts | 222 ++++++++- packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 58 files changed, 1115 insertions(+), 370 deletions(-) create mode 100644 packages/core/agent/src/llm-target.ts create mode 100644 packages/core/agent/tests/llm-target.spec.ts create mode 100644 packages/ui/tui/tests/snapshots/model-selector.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/model-switching.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index bc77edbd0a..810f8863e5 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb +2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 8fbc5dddc0..3e2b1e7510 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -20,9 +20,11 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. + +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -39,6 +41,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. - **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. - **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. +- **Mutate `agent.options` when `/model` runs** — rejected because creation options do not provide an atomic boundary between asynchronous prompt assembly and request routing. Agent-scoped waterfalls preserve immutable creation input and snapshot the selected pair for each step. ## Consequences @@ -46,3 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. +- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6ddc3523b7..5f3632f437 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -20,9 +20,11 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并将选中模型及其推理状态组合显示;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 + +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -39,6 +41,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 - **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 - **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 +- **在 `/model` 运行时修改 `agent.options`**:不予采纳,因为创建选项无法在异步 prompt 组装与请求路由之间提供原子边界。agent 作用域内的 waterfall 会在保持创建输入不可变的同时,为每个 step 快照一次选中的字段组合。 ## 后果 @@ -46,3 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 +- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 198c9fbbaa..d8fe905de2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:211`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -1217,7 +1217,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `userInteraction` · `tools` +Requires: `agents` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1232,14 +1232,20 @@ export interface Config extends TuiConfig { export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -1249,7 +1255,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:124`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bad8b6a302..4d2c9e1de0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:223`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:224`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 52d85d2fb9..394b1da60e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -15,7 +15,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 84068531fe..2a26e2d4fb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -398,6 +398,7 @@ flowchart TD pkg_tui --> pkg_agent_loop pkg_tui --> pkg_llm pkg_tui --> pkg_session + pkg_tui --> pkg_system_prompt pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent @@ -543,7 +544,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 65f92c4916..7db76466bc 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-766ddb6deb99/b7b4bdfcf7aa-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 354a014a63..8ae92a7be9 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..2a612ed289 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..6c8261e7cb 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -89,8 +89,8 @@ {"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","outcome":"allowed-once"}} {"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..1517df75e9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index a812f71445..3cbcb0e571 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,6 +20,8 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local + plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_cordis_token_meter plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] cfg --> plugin_cordis_tui_agent plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] @@ -41,6 +43,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 39b6cd3b48..01dcfc50be 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -44,6 +44,9 @@ - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + # The app bundle pre-creates the self-referential demo's `main` agent. - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 4af7be198f..0e83c8bf3f 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -10,7 +10,7 @@ pnpm run demo:tui The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. Run `pnpm run demo:code-mode tui` for the Code Mode overlay. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 45f5a7af04..a11e86a068 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -19,7 +19,7 @@ welcome: 'TUI Code Mode ready. Give it a multi-tool task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 73b42b284a..4ea97ea0e8 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -31,7 +31,7 @@ welcome: 'TUI agent ready. Give it a coding task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c55b3d355c..020394ebe3 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,5 +1,5 @@ import type { Context } from 'cordis' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' @@ -18,7 +18,17 @@ function textChunks(text: string): StreamChunk[] { /** Keyless two-step adapter for the real-PTY TUI conversation test. */ class ScriptedTuiAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { + override listModels(provider: string): Promise { + return Promise.resolve([ + { provider, id: 'tui-scripted-model', name: 'Scripted Base' }, + { provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' }, + ]) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { + throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + } const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false if (hasToolResult) { for (const chunk of textChunks(FINAL_TEXT)) yield chunk diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index f8de00a1a6..dbe22cb53b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -12,6 +12,9 @@ config: cwd: !!js process.cwd() +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' config: @@ -21,5 +24,6 @@ workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' + persona: 'Scripted model {{model}}.' ui: showReasoning: true diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 29ea17fd66..8aadb202ff 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -67,7 +67,7 @@ buffer style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact" - style 0-58 dim - style 67-99 dim +29| "/tmp/dsh-tui-snapshot-bash-te ↑3.0k ↓115 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 2850e0c7d0..ec3b873efd 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -73,7 +73,7 @@ buffer style 1-1 inverse 30| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +31| "/tmp/dsh-tui-snapshot-code-mo ↑3.1k ↓158 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index e4e6d0a040..02fbde0fe0 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -111,6 +111,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact" - style 0-61 dim - style 67-99 dim +49| "/tmp/dsh-tui-snapshot-cordis-dyn ↑18 ↓18 7% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-31 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 459fddd90c..465ceee4e1 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -101,6 +101,6 @@ buffer style 1-1 inverse 45| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact" - style 0-56 dim - style 67-99 dim +46| "/tmp/dsh-tui-snapshot-dynamic ↑3.5k ↓227 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index d56db72ccb..47eb6889fd 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -64,7 +64,7 @@ buffer style 1-1 inverse 29| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact" - style 0-62 dim - style 67-99 dim +30| "/tmp/dsh-tui-snapshot-multi-tu ↑2.9k ↓41 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-29 dim + style 42-99 dim 31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index f4c3d83b3d..3af0096d1f 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -86,6 +86,6 @@ buffer style 1-1 inverse 37| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact" - style 0-55 dim - style 67-99 dim +38| "/tmp/dsh-tui-snapshot-parallel-fi ↑20 ↓6 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-32 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 527e6e4016..9ba20c4a28 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -76,6 +76,6 @@ buffer style 1-1 inverse 34| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +35| "/tmp/dsh-tui-snapshot-todo-pl ↑3.1k ↓145 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 21be35eef5..19a366b675 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -25,7 +25,7 @@ describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loa expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent conversation', tempDirPrefix: 'tui-agent-conversation-', @@ -33,7 +33,9 @@ describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loa configPath: scriptedConfigPath, tsconfigPath, actions: [ - { waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' }, + { waitFor: 'scripted TUI ready.', send: '/model\r' }, + { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 534207c875..3361b67059 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -14,6 +14,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -168,6 +169,7 @@ async function mountScenarioContext( tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, skills: { local: { agentsHome: join(cwd, '.agents') } }, }) + await ctx.plugin(TokenMeterService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 23a7bfddf0..76b74945a7 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,7 +8,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 061ddae53f..66dce92464 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' +export * from './llm-target.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts new file mode 100644 index 0000000000..98f1e19d7d --- /dev/null +++ b/packages/core/agent/src/llm-target.ts @@ -0,0 +1,66 @@ +/** + * Agent-scoped provider/model target snapshot shared by interactive front doors. + * @module @deepseek-ai/dsh-agent/llm-target + */ + +import type { Context } from 'cordis' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Complete provider/model route selected for one live agent. */ +export interface AgentLlmTarget { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string +} + +/** Mutable selection plus the target captured for the current step. */ +export interface AgentLlmTargetRef { + /** Target selected for the next step that enters prompt assembly. */ + current: AgentLlmTarget | undefined + /** Target captured when the current step entered prompt assembly. */ + assembled: AgentLlmTarget | undefined +} + +/** + * Couple one mutable target to agent-scoped prompt assembly and request routing. + * Prompt assembly snapshots the selected pair before delegating, then applies + * both prompt variables and request config to that snapshot so a concurrent + * switch takes effect on a later step instead of splitting the two surfaces. + * + * @param agentCtx - The target agent's scoped context. + * @param target - Mutable selection owned by the calling front door. + * @returns Disposer for both scoped waterfall listeners. + */ +export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void { + const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const selected = target.current + const assembled = await next() + target.assembled = selected + if (selected === undefined) return assembled + return { + ...assembled, + variables: { + ...assembled.variables, + provider: selected.provider, + model: selected.model, + }, + } + }) + const disposeRequest = agentCtx.on( + 'agent/request', + async (_agent, _turn, _step, _config, next): Promise => { + const resolved = await next() + const selected = target.assembled + return selected === undefined ? resolved : { + ...resolved, + provider: selected.provider, + model: selected.model, + } + }, + ) + return () => { + disposeAssembly() + disposeRequest() + } +} diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts new file mode 100644 index 0000000000..0c9ccb56ec --- /dev/null +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { + agentEvents, + installAgentLlmTarget, + type Agent, + type AgentLlmTargetRef, +} from '../src/index.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +describe('installAgentLlmTarget()', () => { + it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const target: AgentLlmTargetRef = { current: undefined, assembled: undefined } + const dispose = installAgentLlmTarget(ctx, target) + const agent = {} as Agent + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + + target.current = { provider: 'alpha', model: 'a1' } + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) + target.current = { provider: 'beta', model: 'b1' } + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) + + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 1, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + + dispose() + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 2, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b190aeb47d..3c133aab6e 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -42,9 +42,14 @@ import { type Stream, type StopReason, } from '@agentclientprotocol/sdk' -import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget as LlmTarget, + type AgentLlmTargetRef as LlmTargetRef, +} from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -217,19 +222,6 @@ export const Config: Schema = Schema.object({ model: Schema.string(), }) -/** Provider/model pair selected for one ACP session. */ -interface LlmTarget { - provider: string - model: string -} - -/** Mutable target shared by one agent's scoped assembly and request listeners. */ -interface LlmTargetRef { - current: LlmTarget | undefined - /** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */ - assembled: LlmTarget | undefined -} - /** One resolved ACP model selector plus its opaque value lookup. */ interface ModelDirectory { option: Extract | undefined @@ -294,32 +286,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const logged = agent.session.requestHeader()?.config if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model } - // Capture once at assembly entry and apply the same pair after downstream - // prompt listeners. A selector change during async assembly therefore takes - // effect on the following step instead of splitting {{model}} from routing. - agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const selected = target.current - const assembled = await next() - target.assembled = selected - if (selected === undefined) return assembled - return { - ...assembled, - variables: { - ...assembled.variables, - provider: selected.provider, - model: selected.model, - }, - } - }) - agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise => { - const resolved = await next() - const selected = target.assembled - return selected === undefined ? resolved : { - ...resolved, - provider: selected.provider, - model: selected.model, - } - }) + installAgentLlmTarget(agentCtx, target) } /** Opaque ACP value preserving both routing dimensions. */ diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index f44644c430..d569fe39c6 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,13 +4,15 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. -This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The idle footer shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. +While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide terminal-only controls without entering command text into the session. + +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. ## Config @@ -19,10 +21,13 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t | `welcome` | `ready.` | Header subtitle | | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | -| `maxToolOutputLines` | `12` | Collapsed tool-card output limit | -| `maxQuestionOptions` | `8` | Visible options in a question overlay | -| `questionDialogWidth` | `72` | Question-overlay width in columns | -| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows | +| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | +| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxModelOptions` | `8` | Visible models in the model selector | +| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | +| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `modelDialogWidth` | `72` | Model-selector width in columns | +| `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Terminal window title | @@ -34,14 +39,14 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t welcome: 'Coding agent ready.' sessionId: main-session-123 showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 ``` Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience @@ -49,7 +54,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. +Each non-empty non-command editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash command text and keybindings are TUI-only. #### Token effect @@ -59,6 +64,20 @@ Submitted text is retained under the agent loop's normal session-history and com Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Session model selection + +#### What the model sees + +The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing. + +#### Token effect + +The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model. + +#### KV Cache effect + +Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed. + ### Interactive user-question answers #### What the model sees diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..7481d78e58 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -26,6 +26,8 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -41,6 +43,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 35721669fb..dd3e58c6bc 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -13,12 +13,12 @@ import { Editor, Input, Key, - Loader, Markdown, Spacer, Text, TUI, ProcessTerminal, + SelectList, matchesKey, truncateToWidth, visibleWidth, @@ -33,10 +33,21 @@ import { } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget, + type AgentLlmTargetRef, + type AgentStatus, +} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-token-meter' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { FileDiff, @@ -54,20 +65,26 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'userInteraction', 'tools'] +export const inject = ['agents', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -77,10 +94,13 @@ export interface TuiConfig { } const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const questionDialogWidthSchema = z.number().step(1).min(20).default(72) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const modelDialogWidthSchema = z.number().step(1).min(20).default(72) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) const titleSchema = z.string().default('DeepSeek Harness') @@ -90,8 +110,11 @@ export const TuiConfigSchema: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -111,8 +134,11 @@ export const Config: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -123,8 +149,11 @@ export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number maxQuestionOptions: number + maxModelOptions: number questionDialogWidth: number questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number showHardwareCursor: boolean color: boolean title: string @@ -136,6 +165,8 @@ export interface TuiRuntime { terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number } /** @@ -147,10 +178,13 @@ export interface TuiRuntime { export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { return { showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 12, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 72, + maxModelOptions: config?.maxModelOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 72, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, title: config?.title ?? 'DeepSeek Harness', @@ -251,6 +285,13 @@ function selectTheme(palette: Palette): SelectListTheme { } } +function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} + function contentText(content: readonly ContentBlock[]): string { const parts: string[] = [] for (const block of content) { @@ -282,11 +323,52 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning' .join('\n\n') } +interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string +} + +function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) return { provider: logged.provider, model: logged.model } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return models.map((model): ModelChoice => ({ + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + })) + })) + return groups.flat() +} + class HeaderComponent implements Component { constructor( private readonly agent: Agent, private readonly welcome: string, private readonly palette: Palette, + private readonly currentModel: () => string | undefined, ) {} invalidate(): void {} @@ -294,7 +376,7 @@ class HeaderComponent implements Component { render(width: number): string[] { const usable = Math.max(1, width - 4) const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` - const model = displayText(this.agent.options.model ?? 'model unset') + const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) @@ -506,9 +588,15 @@ class ToolCardComponent implements Component { const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') const body = this.renderBody() const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body - : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] const barFn = this.result === undefined ? this.palette.warning : isError ? this.palette.error : this.palette.success @@ -622,19 +710,40 @@ class FooterComponent implements Component { private readonly toolsExpanded: () => boolean, private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, + private readonly currentModel: () => string | undefined, + private readonly contextPercent: () => number, + private readonly runningSeconds: () => number, ) {} invalidate(): void {} render(width: number): string[] { + if (this.agent.status === 'running') { + const interrupt = this.palette.dim('esc interrupt') + const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1) + const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt))) + return [`${activity}${gap}${interrupt}`] + } const { input, output } = this.tokens() - const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` - const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` - const leftStyled = this.palette.dim(left) - const available = Math.max(0, width - visibleWidth(left) - 2) - const rightClipped = truncateToWidth(right, available, '') - const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] + const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` + const model = displayText(this.currentModel() ?? 'model unset') + const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` + const context = `${this.contextPercent()}% context` + const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` + const compactRight = `${context} ${modelState}` + if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { + const compact = truncateToWidth(compactRight, width, '') + return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`] + } + const rightAvailable = width - visibleWidth(counters) - 1 + const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight + const rightClipped = truncateToWidth(right, rightAvailable, '') + const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3) + const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '') + const left = [cwd, counters].filter(Boolean).join(' ') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped))) + return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`] } } @@ -643,6 +752,76 @@ interface QuestionSelection { custom?: string } +function renderDialog( + title: string, + body: readonly string[], + width: number, + palette: Palette, +): string[] { + const innerWidth = Math.max(1, width - 4) + const topLabel = ` ${displayText(title)} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [palette.accent(top)] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) + } + lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines +} + +class ModelDialog implements Component { + private readonly list: SelectList + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (choice: ModelChoice) => void, + cancel: () => void, + ) { + this.list = new SelectList(choices.map(choice => ({ + value: targetLabel(choice), + label: displayText(targetLabel(choice)), + description: [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [], + ].join(' — '), + })), maxVisible, dialogSelectTheme(palette)) + const currentIndex = current === undefined + ? 0 + : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) + this.list.setSelectedIndex(currentIndex) + this.list.onSelect = (item) => { + const selected = choices.find(choice => targetLabel(choice) === item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + done(selected) + } + this.list.onCancel = cancel + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Select model', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'), + ], width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() @@ -654,6 +833,9 @@ class QuestionDialog implements Component, Focusable { constructor( private readonly question: AskUserQuestionItem, + private readonly position: number, + private readonly total: number, + private readonly unanswered: number, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, @@ -694,11 +876,11 @@ class QuestionDialog implements Component, Focusable { } else if (matchesKey(data, Key.enter)) { const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] if (indices.length === 0) { - this.error = 'Select at least one option, or press C for a custom answer.' + this.error = 'Select at least one option, or press Tab for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } else if (data.toLowerCase() === 'c') { + } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { @@ -718,16 +900,13 @@ class QuestionDialog implements Component, Focusable { render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) - const title = displayText(this.question.header ?? 'Question') - const topLabel = ` ${title} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [this.palette.accent(top)] - const push = (line: string): void => { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) - } - for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) - push('') + const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` + const lines = [ + this.palette.muted(header), + ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + '', + ] + const push = (line: string): void => { lines.push(line) } if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) @@ -738,27 +917,45 @@ class QuestionDialog implements Component, Focusable { options.length - this.maxVisible, )) const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) for (let index = start; index < end; index += 1) { // `index < end <= options.length`; the options array is borrowed immutably for this dialog. const option = options[index] as NonNullable[number] - const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' ' const mark = this.question.multiSelect - ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' - : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') - const description = option.description - ? this.palette.muted(` — ${displayText(option.description)}`) + ? this.selected.has(index) ? '[x] ' : '[ ] ' : '' - const line = `${cursor} ${mark} ${displayText(option.label)}${description}` - push(index === this.selectedIndex ? this.palette.selected(line) : line) + const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + const leftStyled = index === this.selectedIndex + ? this.palette.bold(this.palette.accent(left)) + : left + const description = option.description === undefined + ? '' + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) - push(this.palette.dim(this.question.multiSelect - ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel' - : '↑↓ navigate • Enter select • C custom • Esc cancel')) + const hint = this.palette.dim(this.question.multiSelect + ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' + : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) } - if (this.error) push(this.palette.error(this.error)) - lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines + if (this.error) { + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + } + return ['', ...lines, ''].map((line) => { + const clipped = truncateToWidth(line, innerWidth, '') + return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + }) } } @@ -814,7 +1011,6 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() - const statusContainer = new Container() const editor = new Editor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), @@ -823,7 +1019,8 @@ export function createTuiChat( let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined - let statusLoader: Loader | undefined + let runningStartedAt: number | undefined + let statusTicker: ReturnType | undefined let disposed = false let shuttingDown: Promise | undefined const tokens = sessionTokens(agent.session) @@ -832,13 +1029,25 @@ export function createTuiChat( const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] let activeQuestion: PendingQuestion | undefined + let modelOverlay: OverlayHandle | undefined + const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } + let modelCommands = Promise.resolve() + const now = (): number => runtime.now?.() ?? Date.now() const welcome = config.welcome ?? 'ready.' - const header = new HeaderComponent(agent, welcome, palette) - const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model) + const footer = new FooterComponent( + agent, + palette, + () => toolsExpanded, + () => showReasoning, + () => tokens, + () => target.current?.model, + () => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)), + () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), + ) ui.addChild(header) ui.addChild(chat) - ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) @@ -858,10 +1067,98 @@ export function createTuiChat( requestRender() } + const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) + + const selectModel = (selected: ModelChoice): void => { + if (target.current?.provider === selected.provider && target.current.model === selected.model) { + appendNotice(`Model is already ${targetLabel(selected)}.`) + return + } + target.current = { provider: selected.provider, model: selected.model } + appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + modelOverlay?.hide() + modelOverlay = undefined + const close = (): void => { + modelOverlay?.hide() + modelOverlay = undefined + requestRender() + } + const dialog = new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selected) => { + close() + selectModel(selected) + }, + close, + ) + modelOverlay = ui.showOverlay(dialog, { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }) + requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (disposed) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + appendNotice('Usage: /model [provider/]model', 'warning') + return + } + + let matches: ModelChoice[] + if (parts.length === 2) { + matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) + } else { + const value = argument + const qualified = choices.filter(choice => targetLabel(choice) === value) + matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) + } + if (matches.length === 0) { + appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') + return + } + const selected = matches[0] + /* v8 ignore next -- a non-empty matches array always has index zero. */ + if (selected === undefined) return + selectModel(selected) + } + + const queueModelCommand = (raw: string): void => { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + } + const clearStatus = (): void => { - statusLoader?.stop() - statusLoader = undefined - statusContainer.clear() + if (statusTicker !== undefined) clearInterval(statusTicker) + statusTicker = undefined + runningStartedAt = undefined runtime.terminal.setProgress(false) } @@ -869,8 +1166,9 @@ export function createTuiChat( clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) if (status === 'running') { - statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels') - statusContainer.addChild(statusLoader) + runningStartedAt = now() + statusTicker = setInterval(requestRender, 1_000) + statusTicker.unref() runtime.terminal.setProgress(true) } requestRender() @@ -1030,6 +1328,9 @@ export function createTuiChat( } const dialog = new QuestionDialog( question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, palette, (selection) => { @@ -1048,8 +1349,8 @@ export function createTuiChat( pending.overlay = ui.showOverlay(dialog, { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'center', - margin: 1, + anchor: 'bottom-left', + margin: { bottom: 1 }, }) requestRender() } @@ -1089,6 +1390,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true clearStatus() + modelOverlay?.hide() + modelOverlay = undefined if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined @@ -1115,6 +1418,7 @@ export function createTuiChat( editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ { name: 'help', description: 'Show keyboard shortcuts and commands' }, + { name: 'model', description: 'Show or switch this session\'s model' }, { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, { name: 'cancel', description: 'Cancel the active turn' }, { name: 'reasoning', description: 'Toggle reasoning blocks' }, @@ -1146,9 +1450,9 @@ export function createTuiChat( chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', - 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', + 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', - '/help /clear /cancel /reasoning /tools /redraw /exit', + '/help /model /clear /cancel /reasoning /tools /redraw /exit', ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } @@ -1158,6 +1462,10 @@ export function createTuiChat( if (text === '') return editor.addToHistory(text) editor.setText('') + if (/^\/model(?:\s|$)/u.test(text)) { + queueModelCommand(text.slice('/model'.length)) + return + } switch (text) { case '/help': showHelp() @@ -1199,7 +1507,7 @@ export function createTuiChat( } const removeInputListener = ui.addInputListener((data) => { - if (activeQuestion !== undefined) return undefined + if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined if (matchesKey(data, Key.ctrl('o'))) { toggleTools() return { consume: true } @@ -1271,6 +1579,7 @@ export function createTuiChat( disposeStatus() disposeError() disposeAgent() + disposeTargetListeners() } rebuildTranscript(true) diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..28f93f223c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,8 +1,9 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config } from '../src/index.ts' @@ -21,6 +22,15 @@ export interface TuiHarnessOptions { configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null + agentOptions?: AgentOptions + contextWindow?: number + contextTokens?: number + now?: () => number + catalog?: { + providers: LlmProviderInfo[] + models: LlmModelInfo[] + listModels?: (provider: string) => Promise + } } export interface TuiHarness void> { @@ -48,6 +58,28 @@ export async function createTuiTestHarness ({ ...provider })) + }, + listModels(provider: string) { + return catalog.listModels?.(provider) + ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model }))) + }, + } as never) + ctx.provide('tokenMeter', { + contextWindow: options.contextWindow ?? 128_000, + measure() { + return { totalTokens: options.contextTokens ?? 0 } + }, + } as never) if (options.configureContext === undefined) { const tools = options.tools ?? {} ctx.provide('tools', { @@ -58,6 +90,7 @@ export async function createTuiTestHarness 0) }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index d1e4b92f3d..f46371ae17 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -12,7 +12,7 @@ describe('dsh-tui plugin export shape', () => { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index dd563c0614..b2c5547b3f 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -33,11 +33,12 @@ buffer 9| "▌ /workspace/project " style 0-0 fg=green style 2-19 dim -10| "▌ packages/ui/tui 100% " +10| "▌ … +4 lines (Ctrl+O to expand) " style 0-0 fg=green -11| "▌ … 4 more lines (Ctrl+O to expand) " + style 2-30 dim +11| "▌ [exit 0] " style 0-0 fg=green - style 2-34 dim + style 2-9 dim 12| "▌ " style 0-0 fg=green 13| @@ -53,12 +54,12 @@ buffer 17| "▌ - old line " style 0-0 fg=green style 2-11 fg=red -18| "▌ - keep " +18| "▌ … +5 lines (Ctrl+O to expand) " style 0-0 fg=green - style 2-7 fg=red -19| "▌ … 5 more lines (Ctrl+O to expand) " + style 2-30 dim +19| "▌ + expect(screen).toMatchSnapshot() " style 0-0 fg=green - style 2-34 dim + style 2-35 fg=green 20| "▌ " style 0-0 fg=green 21| @@ -102,6 +103,6 @@ buffer style 1-1 inverse 39| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 147d7fcdb1..0f93629223 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -122,6 +122,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded" +49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 66-99 dim + style 41-99 dim diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index 30deac56c6..f52c197d51 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -46,7 +46,7 @@ buffer style 1-1 inverse 16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 18-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 4b6ccdee48..3b84636ca8 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,5 +1,5 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive +lifecycle started=1 stopped=0 progress=active title "DSH snapshot" cursor hidden column=1 viewportRow=17 bufferRow=17 viewport @@ -41,12 +41,12 @@ viewport 15| " Streaming visible state… " style 11-23 bold 16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim + style 0-95 fg=bright-blue 17| " " style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim + style 0-95 fg=bright-blue +19| "◒ Working · 0s esc interrupt" + style 0-13 fg=bright-blue + style 83-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 81d59edfec..01022fee8c 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -53,7 +53,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 21-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 05809dea0c..6259981036 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -25,12 +25,12 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black +10| " /help /model /clear /cancel /reasoning /tools /redraw /exit " + style 1-59 fg=bright-black 11| 12| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow @@ -46,7 +46,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim + style 34-91 dim 21-31| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index 02395a1ff0..31af6ce523 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -33,8 +33,9 @@ buffer style 0-0 fg=yellow 10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " style 0-0 fg=yellow -11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), " +11| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=yellow + style 2-30 dim 12| "▌ ]) " style 0-0 fg=yellow 13| "▌ phase('Verify') " @@ -49,7 +50,7 @@ buffer style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index fccfca604b..9837b28710 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -25,12 +25,12 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black +10| " /help /model /clear /cancel /reasoning /tools /redraw /exit " + style 1-59 fg=bright-black 11| 12| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow @@ -46,7 +46,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim + style 34-91 dim 21-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt new file mode 100644 index 0000000000..4bd9dcbbbc --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -0,0 +1,52 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=31 bufferRow=31 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +6| " " + style 1-1 inverse +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-24 dim + style 34-91 dim +9-12| +13| " ╭ Select model ────────────────────────────────────────────────────────╮ " + style 10-81 fg=bright-blue +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " + style 10-10 fg=bright-blue + style 12-72 fg=bright-blue inverse + style 81-81 fg=bright-blue +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " + style 10-10 fg=bright-blue + style 38-60 fg=bright-black + style 81-81 fg=bright-blue +16| " │ │ " + style 10-10 fg=bright-blue + style 81-81 fg=bright-blue +17| " │ ↑/↓ navigate • Enter select • Esc cancel │ " + style 10-10 fg=bright-blue + style 12-51 dim + style 81-81 fg=bright-blue +18| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 10-81 fg=bright-blue +19-31| diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt new file mode 100644 index 0000000000..7027c435db --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -0,0 +1,35 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=8 bufferRow=8 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-pro • main-session │" + style 0-0 fg=bright-blue + style 2-33 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| +6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 1-64 fg=bright-black +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| " " + style 1-1 inverse +9| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)" + style 0-24 dim + style 36-91 dim +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index a2dc6676e2..44bbecdd2a 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=13 bufferRow=13 +cursor hidden column=56 viewportRow=17 bufferRow=17 viewport 0| "╭──────────────────────────────────────────────────────╮" style 0-55 fg=bright-blue @@ -18,52 +18,30 @@ viewport style 0-0 fg=bright-blue style 2-35 dim style 55-55 fg=bright-blue -4| "╰───╭ Coverage ────────────────────────────────────╮───╯" +4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────│ Which advanced TUI states belong in the │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold - style 52-55 dim -6| " │ required matrix? │ " - style 1-1 inverse - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue -7| "────│ │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -10| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -12| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -13| " │ Select at least one option, or press C for a │ " - style 4-4 fg=bright-blue - style 6-49 fg=red - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +5| " " +6| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +7| " Which advanced TUI states belong in the required " +8| " matrix? " +9| " " +10| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +11| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +12| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +13| " 1/4 " + style 2-4 dim +14| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +15| " Enter submit • Esc interrupt " + style 2-29 dim +16| " Select at least one option, or press Tab for a " + style 2-55 fg=red +17| " custom answer. " + style 2-15 fg=red +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 95dc4f2496..a761c2a3ab 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -20,48 +20,28 @@ viewport style 55-55 fg=bright-blue 4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────╭ Coverage ────────────────────────────────────╮────" - style 0-3 dim - style 4-51 fg=bright-blue - style 52-55 dim -6| " │ Which advanced TUI states belong in the │ " +5| "────────────────────────────────────────────────────────" + style 0-55 dim +6| " " style 1-1 inverse - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold -7| "────│ required matrix? │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ › [ ] Code Mode — run_code programs and capt │ " - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue -10| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -12| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -13| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +7| " " +8| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +9| " Which advanced TUI states belong in the required " +10| " matrix? " +11| " " +12| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +13| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +14| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +15| " 1/4 " + style 2-4 dim +16| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +17| " Enter submit • Esc interrupt " + style 2-29 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index 7c63f3491e..28794f6d7f 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -35,7 +35,6 @@ buffer style 1-1 inverse 12| "────────────────────────────────────────────" style 0-43 dim -13| "/workspace/project ↑0 ↓0 idle reasoning:o" - style 0-24 dim - style 27-43 dim +13| " 0% context deepseek-v4-flash(reasoning:on)" + style 1-43 dim 14-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index c5448befc8..d2e86239f2 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -31,7 +31,7 @@ buffer style 1-1 inverse 10| "────────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-103 dim -11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 71-103 dim + style 46-103 dim 12-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index 5c2bbacb4e..bebdb78357 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -45,8 +45,9 @@ buffer style 2-19 dim 15| "▌ packages/ui/tui 100% " style 0-0 fg=green -16| "▌ 4016 tests passed " +16| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=green + style 2-30 dim 17| "▌ 1 test skipped " style 0-0 fg=green 18| "▌ coverage complete " @@ -62,6 +63,6 @@ buffer style 1-1 inverse 23| "────────────────────────────────────────────────────────────────────────────────" style 0-79 dim -24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 47-79 dim +24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-13 dim + style 22-79 dim diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 1d82ec3c45..4d541b4687 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -49,36 +49,19 @@ buffer 19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green style 2-65 fg=bright-black -20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ " +20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 2-13 dim - style 14-85 fg=bright-blue -21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ " + style 2-54 dim +21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-76 bold - style 85-85 fg=bright-blue -22| "▌ [signal SIG\\│ │ " +22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-0 fg=green - style 2-13 fg=red - style 14-14 fg=bright-blue - style 85-85 fg=bright-blue -23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ " + style 2-58 fg=red +23| "▌ " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-16 fg=bright-blue inverse - style 17-17 inverse - style 18-18 fg=bright-blue inverse - style 19-78 inverse - style 79-83 fg=bright-black inverse - style 85-85 fg=bright-blue -24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ " - style 14-14 fg=bright-blue - style 16-65 dim - style 85-85 fg=bright-blue -25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ " - style 1-13 dim - style 14-85 fg=bright-blue +24| +25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-62 dim 26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-60 fg=bright-black 27| @@ -88,19 +71,17 @@ buffer 30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-63 fg=red 31| -32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -33| -34| "Plan" - style 0-3 fg=bright-blue bold -35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -36| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -37| " " - style 1-1 inverse -38| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +32| " " +33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 fg=bright-black +34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +35| " " +36| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " + style 2-65 fg=bright-blue bold + style 67-97 fg=bright-black +37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " + style 2-64 dim +38| " " +39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 609574e758..2d582d89cf 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -35,6 +35,8 @@ const CHECKPOINTS = [ 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', + 'model-selector', + 'model-switching', 'errors-and-help', 'disposed-terminal', ] as const @@ -196,6 +198,8 @@ describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') appendUser(harness.session, 'Show the live update.') harness.session.append('assistant/chunk', { turn: 2, @@ -382,25 +386,29 @@ describe('TUI terminal-state snapshots', () => { const harness = await setupSnapshot({ config: { maxQuestionOptions: 3, - questionDialogWidth: 48, + questionDialogWidth: 200, questionDialogMaxHeight: 16, }, }, { columns: 56, rows: 20 }) const controller = new AbortController() const beforeQuestion = harness.terminal.frames const answer = harness.ctx.userInteraction.ask({ - questions: [{ - id: 'coverage', - header: 'Coverage', - question: 'Which advanced TUI states belong in the required matrix?', - multiSelect: true, - options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, - { label: 'Workflows', description: 'phases and parallel agents' }, - { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, - { label: 'Compaction', description: 'surface replacement and reflow' }, - ], - }], + questions: [ + { + id: 'coverage', + header: 'Coverage', + question: 'Which advanced TUI states belong in the required matrix?', + multiSelect: true, + options: [ + { label: 'Code Mode', description: 'run_code programs and captured output' }, + { label: 'Workflows', description: 'phases and parallel agents' }, + { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, + { label: 'Compaction', description: 'surface replacement and reflow' }, + ], + }, + { id: 'priority', question: 'Which state should be implemented first?' }, + { id: 'notes', question: 'Any additional constraints?' }, + ], signal: controller.signal, }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) @@ -488,6 +496,21 @@ describe('TUI terminal-state snapshots', () => { await harness.ctx.fiber.dispose() await harness.terminal.dispose() }) + + it('pins the model selector and selection notice', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) + await renderAfter(harness, () => { + harness.terminal.send('/model') + harness.terminal.send('\r') + }) + await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { + harness.terminal.send('\x1b[B') + harness.terminal.send('\r') + }) + await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) }) afterAll(async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8e0df2cc75..c1f412a52c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,8 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -110,14 +111,26 @@ async function dispose(setupResult: Awaited>): Promise< await disposeTuiTestHarness(setupResult) } +function provideTokenMeter(ctx: Context): void { + ctx.provide('tokenMeter', { + contextWindow: 128_000, + measure() { + return { totalTokens: 0 } + }, + } as never) +} + describe('TUI config', () => { it('defaults every direct-call TUI option', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, - maxToolOutputLines: 12, + maxToolOutputLines: 6, maxQuestionOptions: 8, - questionDialogWidth: 72, + maxModelOptions: 8, + questionDialogWidth: 200, questionDialogMaxHeight: 20, + modelDialogWidth: 72, + modelDialogMaxHeight: 20, showHardwareCursor: false, color: true, title: 'DeepSeek Harness', @@ -126,8 +139,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -135,8 +151,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -146,7 +165,11 @@ describe('TUI config', () => { describe('pi-tui chat lifecycle and transcript', () => { it('renders its header, footer, replay, streaming answer, todos, and status', async () => { + let now = 0 const result = await setup({ + contextWindow: 100, + contextTokens: 42, + now: () => now, beforeMount(session) { appendUser(session, 'restored prompt') appendAssistant(session, [ @@ -172,9 +195,19 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') expect(result.terminal.output).toContain('↑1.3k ↓42') + expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)') + result.terminal.resize(52) + await tick() + expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(65) + await tick() + expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(88) + await tick() result.agent.status = 'running' result.ctx.emit('agent/status', result.agent, 'running') + now = 8_000 result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -247,13 +280,13 @@ describe('pi-tui chat lifecycle and transcript', () => { appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) await tick() - expect(result.terminal.output).toContain('Working') + expect(result.terminal.output).toContain('◒ Working · 8s') + expect(result.terminal.output).toContain('esc interrupt') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.output).toContain('final live answer') - expect(result.terminal.output).toContain('↑1.8k ↓50') expect(result.terminal.progress).toContain(true) result.session.append('assistant/chunk', { @@ -270,6 +303,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() + expect(result.terminal.output).toContain('↑1.8k ↓50') + expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)') expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) expect(result.terminal.stopped).toBe(1) @@ -377,6 +412,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') result.agent.status = 'running' + result.ctx.emit('agent/status', result.agent, 'running') result.terminal.send('steer it') result.terminal.send('\r') expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]]) @@ -431,6 +467,162 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { + const result = await setup({ + agentOptions: { provider: 'alpha', model: 'a1' }, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }], + models: [ + { provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' }, + { provider: 'alpha', id: 'shared', name: 'Alpha Shared' }, + { provider: 'beta', id: 'b1', name: 'Beta One' }, + { provider: 'beta', id: 'shared', name: 'Beta Shared' }, + ], + }, + }) + + for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) { + result.terminal.send(command) + result.terminal.send('\r') + await tick() + } + expect(result.terminal.output).toContain('Usage: /model') + expect(result.terminal.output).toContain('Unknown model: missing') + expect(result.terminal.output).toContain('advertised by multiple providers') + expect(result.terminal.output).toContain('already alpha/a1') + + result.agent.status = 'running' + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Model selected: beta/b1') + expect(result.agent.sent).toEqual([]) + expect(result.agent.steered).toEqual([]) + + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + result.terminal.send('\x1b') + await tick() + expect(result.agent.cancelled).not.toContain('cancelled from terminal') + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)') + + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) + const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 } + const request = await agentEvents(result.ctx, result.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + ) + expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + await dispose(result) + }) + + it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => { + const resumed = await setup({ + agentOptions: { provider: 'alpha', model: 'configured' }, + catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] }, + beforeMount(session) { + session.append('request/header', { + header: { config: { provider: 'beta', model: 'private' } }, + reason: 'initial', + }) + }, + }) + resumed.terminal.send('/model') + resumed.terminal.send('\r') + await tick() + expect(resumed.terminal.output).toContain('Select model') + expect(resumed.terminal.output).toContain('beta/private') + expect(resumed.terminal.output).toContain('private — current') + await dispose(resumed) + + const unset = await setup({ + agentOptions: {}, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + }, + }) + unset.terminal.send('/model') + unset.terminal.send('\r') + await tick() + unset.terminal.send('\r') + await tick() + expect(unset.terminal.output).toContain('Model selected: alpha/a1') + await dispose(unset) + + const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) + empty.terminal.send('/model') + empty.terminal.send('\r') + await tick() + expect(empty.terminal.output).toContain('Current model: unset') + expect(empty.terminal.output).toContain('No models are advertised') + const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent)) + expect(assembly.variables).toEqual({}) + const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' } + await expect(agentEvents(empty.ctx, empty.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await dispose(empty) + + const failed = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => Promise.reject(new Error('catalog offline')), + }, + }) + failed.terminal.send('/model') + failed.terminal.send('\r') + await tick() + expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline') + await dispose(failed) + }) + + it('does not render a model catalog that resolves after TUI disposal', async () => { + const deferred = Promise.withResolvers() + const result = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => deferred.promise, + }, + }) + result.terminal.send('/model') + result.terminal.send('\r') + await result.controller.dispose() + deferred.resolve([]) + await tick() + expect(result.terminal.output).not.toContain('Available models') + await result.ctx.fiber.dispose() + + const rejected = Promise.withResolvers() + const rejectedResult = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => rejected.promise, + }, + }) + rejectedResult.terminal.send('/model') + rejectedResult.terminal.send('\r') + await rejectedResult.controller.dispose() + rejected.reject(new Error('late catalog failure')) + await tick() + expect(rejectedResult.terminal.output).not.toContain('late catalog failure') + await rejectedResult.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') @@ -525,7 +717,7 @@ describe('tool cards and surface replay', () => { } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { - const result = await setup({ tools, config: { maxToolOutputLines: 1 } }) + const result = await setup({ tools, config: { maxToolOutputLines: 4 } }) const calls = [ ['c1', 'bash', '{"command":"printf hello"}'], ['c2', 'signal', '{}'], @@ -594,7 +786,7 @@ describe('tool cards and surface replay', () => { const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') - expect(output).toContain('more lines') + expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') expect(output).toContain('Edit files') expect(output).toContain('Inspected') @@ -611,6 +803,11 @@ describe('tool cards and surface replay', () => { result.terminal.send('/redraw') result.terminal.send('\r') await tick() + const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(collapsed).toContain('Run command') + expect(collapsed).toContain('[exit 0]') + expect(collapsed).not.toContain('▌ hello') + expect(collapsed).not.toContain('world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') @@ -664,6 +861,7 @@ describe('TUI user-interaction dialogs', () => { }) await tick() expect(result.terminal.output).toContain('Choose a mode') + expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode') expect(result.terminal.output).toContain('1/2') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -683,7 +881,7 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() - result.terminal.send('c') + result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] }) @@ -757,9 +955,11 @@ describe('TUI user-interaction dialogs', () => { ], }) await tick() + expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Second?') + expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)') result.terminal.send('done') result.terminal.send('\r') await expect(batch).resolves.toEqual({ answers: [ @@ -806,6 +1006,7 @@ describe('TUI user-interaction dialogs', () => { describe('terminal mounting', () => { it('starts immediately when the configured agent already exists', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -824,6 +1025,7 @@ describe('terminal mounting', () => { it('waits for its configured agent before starting the TUI', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -852,6 +1054,7 @@ describe('terminal mounting', () => { it('prints a matching live startup failure and exits instead of waiting forever', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -879,6 +1082,7 @@ describe('terminal mounting', () => { it('renders an uncoercible startup failure without escaping the display boundary', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -899,6 +1103,7 @@ describe('terminal mounting', () => { it('rolls back providers, listeners, and terminal state when startup fails', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -929,6 +1134,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..8e84c47555 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/token-meter" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c3c84521..857cb20197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2192,6 +2192,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../cordis/tool-cordis From e92b34bd7e42cc84ddcbe9233c30e5a58ba53e5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:16:08 +0800 Subject: [PATCH 33/48] fix(invariants): enforce runtime relationships --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 7 +- ...-package-invariant-runtime-contracts.zh.md | 11 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- docs/testing.md | 2 - .../context/time-context/src/invariant.ts | 43 +++++- .../time-context/tests/invariant.spec.ts | 61 +++++++- packages/llm/llm-retry/README.md | 2 + packages/llm/llm-retry/package.json | 7 + packages/llm/llm-retry/src/invariant.ts | 97 ++++++++++++ .../llm/llm-retry/tests/invariant.spec.ts | 144 ++++++++++++++++++ packages/llm/llm-retry/tsconfig.json | 3 + packages/sdk/telemetry/README.md | 2 - .../sdk/telemetry/src/redaction-contract.ts | 27 ---- .../tests/redaction-contract.spec.ts | 18 --- packages/support/invariants/README.md | 4 +- pnpm-lock.yaml | 3 + scripts/package-invariants.spec.ts | 14 ++ scripts/package-invariants.ts | 15 +- scripts/test-invariants.spec.ts | 1 + scripts/test-invariants.ts | 1 + 22 files changed, 398 insertions(+), 75 deletions(-) create mode 100644 packages/llm/llm-retry/src/invariant.ts create mode 100644 packages/llm/llm-retry/tests/invariant.spec.ts delete mode 100644 packages/sdk/telemetry/src/redaction-contract.ts delete mode 100644 packages/sdk/telemetry/tests/redaction-contract.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4750c9f9b1..4e0e5cbac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: c8ef723778a3dc704e781b4cfe32705e67f81b5f -2026-07-19-package-invariant-runtime-contracts.zh.md: 7df7ed1ac0743fab3302c47955221721e11fe8c7 +2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3 +2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index c8ef723778..84c632e57b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -27,7 +27,7 @@ The central `dsh-invariants` service owns only configuration, registration uniqu ### Implemented checks -The current 93-package workspace has 18 executable companions and 75 justified empty companions. +The current 94-package workspace has 19 executable companions and 75 justified empty companions. | Owner | Runtime relationship | |---|---| @@ -36,6 +36,7 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | | `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | | `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | +| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. | | `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | | `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | | `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | @@ -48,13 +49,13 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | -| `dsh-time-context` | Plugin-attributed clock readings agree across turn, step, elapsed baseline, rendered time, and event time. | +| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. | Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 7df7ed1ac0..51734f6922 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -1,6 +1,6 @@ -# Agent Note:有意义的包不变量契约 +# Agent Note: 有意义的包不变量契约 -状态:已实施 +Status: implemented [English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 @@ -27,7 +27,7 @@ ### 已实施的检查 -当前 93 个包的 workspace 包含 18 个可执行 companion 和 75 个有理由的空 companion。 +当前 94 个包的 workspace 包含 19 个可执行 companion 和 75 个有理由的空 companion。 | 所有者 | 运行时关系 | |---|---| @@ -36,6 +36,7 @@ | `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | | `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | | `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | | `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | | `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | @@ -48,13 +49,13 @@ | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 在 turn、step、elapsed baseline、渲染时间和事件时间之间保持一致。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 | 基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0651237396..3b8fb21176 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 388caec4d1..4460e1764c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -240,6 +240,7 @@ flowchart TD pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout @@ -596,7 +597,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | diff --git a/docs/testing.md b/docs/testing.md index 32ef0716d4..c158c9b922 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,6 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. - ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 804d419627..134d2233ee 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,8 +18,39 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one plugin-attributed time reading against its durable event timestamp. */ -function validateReading(event: SessionEvent<'context/message'>, fail: InvariantFailure): void { +/** Derive the pre-step position at which a time-context reading may append. */ +function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } { + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const event of session.events.slice().reverse()) { + if (event.type === 'turn/end') { + fail('time-context reading must be appended inside an open turn') + } + if (event.type === 'turn/start') { + openTurn = event.data.turn + break + } + currentTurnEvents.push(event) + } + if (openTurn === undefined) fail('time-context reading must be appended inside an open turn') + + for (const event of currentTurnEvents) { + if (event.type === 'step/start') { + fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`) + } + if (event.type === 'step/end') { + return { turn: openTurn, step: event.data.step + 1 } + } + } + return { turn: openTurn, step: 1 } +} + +/** Validate one plugin-attributed time reading against its session position and timestamp. */ +function validateReading( + session: Session, + event: SessionEvent<'context/message'>, + fail: InvariantFailure, +): void { const [block] = event.data.content if (event.data.content.length !== 1 || block?.type !== 'text') { fail('time-context messages must contain exactly one text block') @@ -31,6 +62,10 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { fail('time-context turn and step must be positive safe integers') } + const expected = preparationPosition(session, fail) + if (turn !== expected.turn || step !== expected.step) { + fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) + } const baseline = match[4] if ((step === 1) !== (baseline === 'model-visible message')) { fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) @@ -49,11 +84,11 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const event = (args as [Session, SessionEvent])[1] + const [session, event] = args as [Session, SessionEvent] if (event.type !== 'context/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(event, fail) + validateReading(session, event, fail) }, { global: true }) } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index eab422e95e..b19b56c99f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -36,12 +36,56 @@ function reading( + `Elapsed since the preceding ${baseline}: unavailable.` } +function preparing(turn: number, step: number): Session { + const session = new Session(SessionId(`time-invariant-${turn}-${step}`)) + for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { + session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } }) + } + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + for (let priorStep = 1; priorStep < step; priorStep += 1) { + session.append('step/start', { turn, step: priorStep }) + session.append('step/end', { turn, step: priorStep }) + } + return session +} + describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' + 'Elapsed since the preceding step context: 4m 2s.' - expect(() => { ctx.emit('session/event', {} as Session, event(text)) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() + }) + + it.each([ + [reading('1', '3', 'step context'), /expected turn 2\/step 3/], + [reading('2', '2', 'step context'), /expected turn 2\/step 3/], + ])('rejects a reading that disagrees with its session position', async (text, message) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message) + }) + + it('rejects a reading after cancellation closes the turn', async () => { + const ctx = await setup() + const session = preparing(1, 2) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) + .toThrow(/inside an open turn/) + }) + + it('rejects a reading after step/start or without any open turn', async () => { + const ctx = await setup() + const started = preparing(1, 1) + started.append('step/start', { turn: 1, step: 1 }) + expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/) + expect(() => { + ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) + }).toThrow(/inside an open turn/) }) it.each([ @@ -61,8 +105,13 @@ describe('time-context invariants', () => { ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { const ctx = await setup() + const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1 expect(() => { - ctx.emit('session/event', {} as Session, event(text, time, content === undefined ? undefined : [...content])) + ctx.emit('session/event', preparing(1, preparationStep), event( + text, + time, + content === undefined ? undefined : [...content], + )) }).toThrow(message) }) @@ -70,11 +119,11 @@ describe('time-context invariants', () => { const ctx = await setup() const other = event('unrelated') as SessionEvent<'context/message'> other.data.source = { kind: 'plugin', plugin: 'other' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() expect(() => { - ctx.emit('session/event', {} as Session, { + ctx.emit('session/event', preparing(1, 1), { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) ctx.emit('tools/change') diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index f84fdba09a..22e4cb43e5 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,6 +6,8 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay. + ```yaml - name: '@deepseek-ai/dsh-llm-retry' config: diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index fce40dbe33..6d6c27636c 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -11,10 +11,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -22,6 +27,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -35,6 +41,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts new file mode 100644 index 0000000000..b925e9148d --- /dev/null +++ b/packages/llm/llm-retry/src/invariant.ts @@ -0,0 +1,97 @@ +/** Package-owned durable retry-event invariants. @module @deepseek-ai/dsh-llm-retry/invariant */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' + +/** Cordis companion plugin name. */ +export const name = 'llm-retry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate one retry record against the open turn and most recently closed step. */ +function validateRetry( + history: readonly SessionEvent[], + event: SessionEvent<'llm/retry'>, + fail: InvariantFailure, +): void { + const { turn, step, retry, maxRetries, delayMs } = event.data + if (!Number.isSafeInteger(retry) || retry < 1) { + fail('llm/retry retry must be a positive safe integer') + } + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } + if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) { + fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`) + } + + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const prior of history.slice().reverse()) { + if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') + if (prior.type === 'turn/start') { + openTurn = prior.data.turn + break + } + currentTurnEvents.push(prior) + } + if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') + if (turn !== openTurn) { + fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) + } + + let closedStep: number | undefined + for (const prior of currentTurnEvents) { + if (prior.type === 'step/start') { + fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) + } + if (prior.type === 'step/end') { + closedStep = prior.data.step + break + } + } + if (closedStep === undefined || step !== closedStep) { + fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) + } + + const priorRetries = currentTurnEvents + .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') + if (priorRetries.some(prior => prior.data.step === step)) { + fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) + } + const priorRetry = priorRetries[0] + if (priorRetry !== undefined && retry <= priorRetry.data.retry) { + fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`) + } +} + +/** Validate every retry record already present in one loaded session. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const [index, event] of session.events.entries()) { + if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) + } +} + +/** Install validation for loaded and newly appended retry records. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('session/created', (session) => { validateSession(session, fail) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type === 'llm/retry') validateRetry(session.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the LLM retry 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)) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts new file mode 100644 index 0000000000..4ffffd4c4b --- /dev/null +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(RetryInvariant) + return ctx +} + +function closeStep(ctx: Context, id: string, turn = 1, step = 1) { + const session = ctx.sessions.create(SessionId(id)) + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn, step }) + session.append('step/end', { turn, step }) + return session +} + +const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } + +describe('llm-retry invariants', () => { + it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-valid') + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, + }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('llm/retry', { + turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, + }) + }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it.each([ + [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], + [{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/], + [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], + ])('rejects invalid retry bounds %#', async (data, message) => { + const ctx = await setup() + const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + }).toThrow(message) + }) + + it('rejects retry records outside the matching closed-step boundary', async () => { + const ctx = await setup() + const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) + expect(() => { + absent.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + + const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') + expect(() => { + wrongTurn.append('llm/retry', { + turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/open turn is 1/) + + const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) + openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + openStep.append('step/start', { turn: 1, step: 1 }) + expect(() => { + openStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/step 1 is still open/) + + const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) + noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => { + noStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is undefined/) + + const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') + expect(() => { + wrongStep.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is 1/) + + const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { + closedTurn.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + }) + + it('rejects duplicate and non-increasing retry records', async () => { + const ctx = await setup() + const duplicate = closeStep(ctx, 'retry-invariant-duplicate') + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + expect(() => { + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/duplicates the retry record/) + + const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') + nonIncreasing.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + nonIncreasing.append('step/start', { turn: 1, step: 2 }) + nonIncreasing.append('step/end', { turn: 1, step: 2 }) + expect(() => { + nonIncreasing.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/must increase/) + }) + + it('validates existing histories on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('retry-invariant-late')) + session.append('step/end', { turn: 1, step: 1 }) + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) + }) +}) diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json index 44310af6e9..48c858951a 100644 --- a/packages/llm/llm-retry/tsconfig.json +++ b/packages/llm/llm-retry/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/agent" }, + { + "path": "../../support/invariants" + }, { "path": "../../util/timeout" } diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 4ff1cf1387..01a2ee6c2d 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -10,8 +10,6 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -The separately published `./invariant` diagnostic companion samples the security-critical redaction boundary: credential values must disappear, ordinary package metadata must survive, and a second redaction pass must be idempotent. - Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. diff --git a/packages/sdk/telemetry/src/redaction-contract.ts b/packages/sdk/telemetry/src/redaction-contract.ts deleted file mode 100644 index c18a582308..0000000000 --- a/packages/sdk/telemetry/src/redaction-contract.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Structural redactor surface sampled by the telemetry invariant. */ -export interface TelemetryRedactionBoundary { - /** Redact credential material in free-form telemetry content. */ - redactText(value: string): string -} - -/** - * Validate the security-critical telemetry redaction boundary. - * @param redactor - candidate content redactor. - * @param placeholder - expected replacement for a secret value. - * @param packageName - ordinary package metadata that must survive redaction. - * @returns the violated contract, or `undefined` when redaction is safe and idempotent. - */ -export function telemetryRedactionViolation( - redactor: TelemetryRedactionBoundary, - placeholder: string, - packageName: string, -): string | undefined { - const secret = 'sk-abcdefghij1234567890' - const redacted = redactor.redactText(`apiKey: ${secret}\nname: ${packageName}\n`) - return !redacted.includes(secret) - && redacted.includes(`apiKey: ${placeholder}`) - && redacted.includes(`name: ${packageName}`) - && redactor.redactText(redacted) === redacted - ? undefined - : 'telemetry redaction must remove credential values, preserve package metadata, and remain idempotent' -} diff --git a/packages/sdk/telemetry/tests/redaction-contract.spec.ts b/packages/sdk/telemetry/tests/redaction-contract.spec.ts deleted file mode 100644 index 0fcc05e5b4..0000000000 --- a/packages/sdk/telemetry/tests/redaction-contract.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SecretRedactor } from '../src/secret-redactor.ts' -import { telemetryRedactionViolation } from '../src/redaction-contract.ts' - -describe('telemetryRedactionViolation', () => { - it('accepts the shipped redactor and rejects one that preserves credentials', () => { - expect(telemetryRedactionViolation( - new SecretRedactor(), - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBeUndefined() - expect(telemetryRedactionViolation( - { redactText: value => value }, - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBe('telemetry redaction must remove credential values, preserve package metadata, and remain idempotent') - }) -}) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 4cd2aa1f9b..f05855274f 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -35,12 +35,12 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| | `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | -| `dsh-llm`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | +| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | -| `dsh-time-context` | Durable clock readings agree with their turn, step, elapsed baseline, and event timestamp. | +| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. | The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 573c95a2a4..c616cdf9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 1014ce5033..54d777ac9a 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -138,6 +138,20 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo .toContain('install function must use its bound failure reporter') }) + it('rejects registering a different installer than the checked local function', () => { + const decoy = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') } +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register('@deepseek-ai/dsh-probe', () => {}) +`, + }) + expect(collectPackageInvariantViolations(decoy).map(violation => violation.message)) + .toContain('line 6: ctx.invariants.register must use the checked local install function') + }) + it('accepts explained empty installers and rejects unexplained ones', () => { const explained = ` export const name = 'probe-invariant' diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 6b099c084d..931f6ff588 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -167,12 +167,18 @@ function checkSource( const constants = topLevelStringConstants(sourceFile) const registrations: string[] = [] const unresolved: number[] = [] + const mismatchedInstallers: number[] = [] const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1 const argument = node.arguments[0] const packageName = argument === undefined ? undefined : stringValue(argument, constants) - if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1) + if (packageName === undefined) unresolved.push(line) else registrations.push(packageName) + const installer = node.arguments[1] + if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') { + mismatchedInstallers.push(line) + } } ts.forEachChild(node, visit) } @@ -185,6 +191,13 @@ function checkSource( `line ${line}: ctx.invariants.register package name must resolve to a local string constant`, ) } + for (const line of mismatchedInstallers) { + addViolation( + violations, + owner.sourcePath, + `line ${line}: ctx.invariants.register must use the checked local install function`, + ) + } if (registrations.length !== 1 || registrations[0] !== owner.packageName) { addViolation( violations, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 52182cafaf..c16c9402b5 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -83,6 +83,7 @@ describe('global test invariant host', () => { '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 2f5eec6271..1f218e16f9 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -42,6 +42,7 @@ export const MANUAL_INVARIANT_TESTS = [ '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', From a4be4b5e5264e1016cec414d7d43c03c0e15c3d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:29:22 +0800 Subject: [PATCH 34/48] docs(invariants): codify semantic review rules --- .agents/skills/dsh-code-review/SKILL.md | 5 +++-- AGENTS.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 814132d481..925dc5b6ac 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -23,7 +23,8 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry. 4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). -5. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. +5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)). +6. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. ## Manual checks @@ -38,7 +39,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. - **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export. - **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. -- **Mechanized invariants and negative controls:** trace each new or changed check through the executed top-level gate and its deliberately invalid case; confirm the real runner fails for the intended rule. +- **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. - **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review expected-output diffs as behavior changes, not formatting noise. - **Bilingual changes:** compare meaning and terminology on both sides; a green pairing hash does not prove translation quality. diff --git a/AGENTS.md b/AGENTS.md index 860f60f1e0..fc39c69df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). From 0c08e34a4ab2a7eaa399485fadfb385fd2c6c2f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:25:38 +0800 Subject: [PATCH 35/48] fix(invariants): harden runtime contracts and gates --- .../2026-07-05-reconstructable-requests.md | 4 +- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 10 ++-- ...-package-invariant-runtime-contracts.zh.md | 10 ++-- package.json | 3 +- packages/context/time-context/README.md | 2 + .../context/time-context/src/invariant.ts | 4 +- .../time-context/tests/invariant.spec.ts | 14 +++-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/invariant.ts | 7 ++- packages/core/agent-loop/src/loop.ts | 5 +- .../core/agent-loop/src/request-marker.ts | 22 ++++++++ .../core/agent-loop/tests/invariant.spec.ts | 47 +++++++++++++---- .../agent-spine-demo/tests/agent-core.spec.ts | 2 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/src/invariant.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 6 ++- packages/llm/llm-retry/tests/retry.spec.ts | 23 +++++++++ packages/support/invariants/README.md | 4 +- packages/support/invariants/src/index.ts | 33 ++++++------ .../support/invariants/tests/service.spec.ts | 22 ++++++++ scripts/package-invariants.spec.ts | 9 ++++ scripts/package-invariants.ts | 16 ++++++ scripts/run-gates.ts | 10 ++++ scripts/test-invariants.spec.ts | 44 +++++++--------- scripts/test-invariants.ts | 39 +++++--------- scripts/verify-built-package-invariants.mjs | 51 +++++++++++++++++++ 27 files changed, 288 insertions(+), 111 deletions(-) create mode 100644 packages/core/agent-loop/src/request-marker.ts create mode 100644 scripts/verify-built-package-invariants.mjs diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 1303a77c42..40cd4180e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant by the unfrozen-request marker. +**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop applies an internal non-enumerable identity before freezing each request; the independently built companion recognizes that identity, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4e0e5cbac0..de5e18bb20 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3 -2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb +2026-07-19-package-invariant-runtime-contracts.md: ab4301a80b01ac2b4b2870f02c232ab321a6fab1 +2026-07-19-package-invariant-runtime-contracts.zh.md: 2e0816b2f381fd8fa51df51d8c482d6d4adf48b4 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 84c632e57b..ab4301a80b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -34,9 +34,9 @@ The current 94-package workspace has 19 executable companions and 75 justified e | `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. | | `dsh-agent` | Non-repeating agent status and terminal disposal transitions. | | `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | -| `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | +| `dsh-agent-loop` | Explicitly marked, frozen loop request reconstruction from the session event log. | | `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | -| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. | +| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and non-negative timer bounds. | | `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | | `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | | `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | @@ -49,15 +49,15 @@ The current 94-package workspace has 19 executable companions and 75 justified e | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | -| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. | +| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. | Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 51734f6922..2e0816b2f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -34,9 +34,9 @@ Status: implemented | `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 | | `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 | | `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | -| `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | +| `dsh-agent-loop` | 从 session 事件日志重建带显式标记的冻结 loop 请求。 | | `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | -| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | | `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | | `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | @@ -49,15 +49,15 @@ Status: implemented | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | 基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 diff --git a/package.json b/package.json index 00a9da4d3d..5f1441c115 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", + "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", @@ -78,7 +79,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index dc79fb6d23..2327da81b2 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -26,6 +26,8 @@ Step 1 measures from the latest preceding model-visible message, including the p A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback. +The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading. + The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one. ## Model Experience diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 134d2233ee..c3d291c7af 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -75,8 +75,8 @@ function validateReading( if (rendered === undefined) fail('time-context reading omitted its rendered timestamp') const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, '')) if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) - || event.time < renderedTime || event.time - renderedTime >= 1_000) { - fail('time-context rendered timestamp must identify the durable event second') + || event.time < renderedTime) { + fail('time-context rendered timestamp must parse and not postdate its durable event') } } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index b19b56c99f..4bcd0f6774 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -62,6 +62,13 @@ describe('time-context invariants', () => { expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() }) + it('accepts a reading durably appended after a long process pause', async () => { + const ctx = await setup() + expect(() => { + ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000)) + }).not.toThrow() + }) + it.each([ [reading('1', '3', 'step context'), /expected turn 2\/step 3/], [reading('2', '2', 'step context'), /expected turn 2\/step 3/], @@ -96,10 +103,9 @@ describe('time-context invariants', () => { [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/], [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/], [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/], - [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /durable event second/], - [reading(), Number.NaN, undefined, /durable event second/], - [reading(), SECOND - 1, undefined, /durable event second/], - [reading(), SECOND + 1_000, undefined, /durable event second/], + [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/], + [reading(), Number.NaN, undefined, /must parse and not postdate/], + [reading(), SECOND - 1, undefined, /must parse and not postdate/], ['ignored', SECOND, [], /exactly one text block/], ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/], ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 72558fd740..e2bdd34d03 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ### Invariant companion -The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract. +The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id. ### Configuration (schemastery) diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index ab6a885dc0..17e55af6ca 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -7,6 +7,7 @@ import type { Context } from 'cordis' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { isLoopRequest } from './request-marker.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' @@ -20,9 +21,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant // Prepend prevents a short-circuiting replay listener from silencing the // check; correctness itself comes from the sequence-bounded reconstruction. ctx.on('llm/stream', (options: GenerateOptions, next) => { - if (options.sessionId === undefined || !Object.isFrozen(options)) return next() + if (!isLoopRequest(options)) return next() + if (!Object.isFrozen(options)) fail('a loop-built request must be frozen') + if (options.sessionId === undefined) fail('a loop-built request must carry a session id') const session = ctx.sessions.get(options.sessionId) - if (!session) return next() + if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`) if (!Object.isFrozen(options.messages)) { fail('a loop-built request must carry a frozen messages array') } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 97a32e2f38..c14e126566 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -15,6 +15,7 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' import type { TransmissionLog } from './request-log.ts' +import { markLoopRequest } from './request-marker.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -619,7 +620,7 @@ async function runStep( recordRequestHeader(session, transmission, header) // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. - const request: GenerateOptions = deepFreeze({ + const request: GenerateOptions = deepFreeze(markLoopRequest({ provider: header.config.provider, model: header.config.model, messages: [...header.messagePrefix ?? [], ...boundaryMessages], @@ -630,7 +631,7 @@ async function runStep( ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, sessionId: session.id, signal, - }) + })) // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() diff --git a/packages/core/agent-loop/src/request-marker.ts b/packages/core/agent-loop/src/request-marker.ts new file mode 100644 index 0000000000..ac8c750ea1 --- /dev/null +++ b/packages/core/agent-loop/src/request-marker.ts @@ -0,0 +1,22 @@ +/** Internal identity shared by the independently bundled loop and invariant companion. */ + +const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request') + +/** + * Mark a request as owned by the agent loop before it is frozen. + * @param request - mutable request object being assembled by the loop. + * @returns the same request with a non-enumerable loop identity. + */ +export function markLoopRequest(request: T): T { + Object.defineProperty(request, LOOP_REQUEST, { value: true }) + return request +} + +/** + * Test whether a request carries the agent loop's internal identity. + * @param request - request observed at the LLM stream boundary. + * @returns whether the loop marked this exact request object. + */ +export function isLoopRequest(request: object): boolean { + return Reflect.get(request, LOOP_REQUEST) === true +} diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index a61cda6f99..c5030bf161 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +import { markLoopRequest } from '../src/request-marker.ts' async function setup(): Promise { const ctx = new Context() @@ -16,6 +17,10 @@ function dispatch(ctx: Context, options: unknown): void { void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) } +function loopRequest(options: T): Readonly { + return Object.freeze(markLoopRequest(options)) +} + async function requestSetup() { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-check')) @@ -30,14 +35,14 @@ async function requestSetup() { describe('request-reconstruction invariant', () => { it('accepts a frozen request equal to the boundary derivation and folded header', async () => { const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) it('uses the step boundary rather than content appended afterward', async () => { const { ctx, session, boundary } = await requestSetup() session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) @@ -45,20 +50,20 @@ describe('request-reconstruction invariant', () => { const { ctx, session, boundary } = await requestSetup() const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) .not.toThrow() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) }) it('rejects message and header divergence', async () => { const { ctx, session, boundary } = await requestSetup() const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) .toThrow(/diverges from the folded request header/) }) @@ -66,7 +71,7 @@ describe('request-reconstruction invariant', () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-bare')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) + const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) session.append('step/start', { turn: 1, step: 1 }) expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) @@ -74,12 +79,34 @@ describe('request-reconstruction invariant', () => { it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => { const { ctx, session, boundary } = await requestSetup() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) }) .toThrow(/frozen messages array/) expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow() expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }) .not.toThrow() + + const directSession = ctx.sessions.create(SessionId('direct-one-shot')) + expect(() => { + dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id })) + }).not.toThrow() + }) + + it('rejects malformed requests carrying the loop marker', async () => { + const { ctx, session } = await requestSetup() + expect(() => { + dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })) + }).toThrow(/request must be frozen/) + expect(() => { + dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) })) + }).toThrow(/carry a session id/) + expect(() => { + dispatch(ctx, loopRequest({ + model: 'm', + messages: Object.freeze([]), + sessionId: SessionId('missing-loop-session'), + })) + }).toThrow(/live session id/) }) it('prepends ahead of a short-circuiting stream listener', async () => { @@ -93,7 +120,7 @@ describe('request-reconstruction invariant', () => { session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - const divergent = Object.freeze({ + const divergent = loopRequest({ model: 'm', messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), sessionId: session.id, diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 9c4fc916a0..1e4fee8b97 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -521,7 +521,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(typeof unwrapped.apply).toBe('function') }) - it('keeps every invariant companion loadable through the real Loader unwrap path', () => { + it('keeps each standard-spine invariant companion loadable through the real Loader unwrap path', () => { const loader = Object.create(Loader.prototype) as Loader for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) { expect('default' in companion).toBe(false) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 22e4cb43e5..699e7e3dad 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,7 +6,7 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. -The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay. +The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index b925e9148d..784f459606 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -26,8 +26,8 @@ function validateRetry( if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) } - if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) { - fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`) + if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) { + fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`) } const currentTurnEvents: SessionEvent[] = [] diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 4ffffd4c4b..81b1aca16d 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -36,6 +36,10 @@ describe('llm-retry invariants', () => { session.append('llm/retry', { turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, }) + const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') + zeroDelay.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure, + }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) @@ -46,7 +50,7 @@ describe('llm-retry invariants', () => { [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], - [{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/], + [{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/], [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], ])('rejects invalid retry bounds %#', async (data, message) => { const ctx = await setup() diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 8e2f086e97..bf6871668d 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -232,6 +232,29 @@ describe('bounded transient retry policy', () => { }) }) + it('accepts the zero-delay lower jitter bound', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('busy', 'SERVER'), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 1, + }, undefined, { random: () => 0 })) + const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(0) + + const idle = waitForIdle(context, agent) + await vi.runAllTimersAsync() + await idle + expect(adapter.requests).toHaveLength(2) + }) + it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => { vi.useFakeTimers() const accepted = new ScriptedAdapter([ diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index f05855274f..a650558cd7 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -40,7 +40,7 @@ The current executable companions protect these relationships: | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | -| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. | +| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position and elapsed baseline; rendered time parses and does not postdate its event. | The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. @@ -77,6 +77,6 @@ None; invariant checks do not assemble or send provider requests. ## Known Limitations and Deferred Work -- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot LLM calls remain outside that marker contract. +- Request reconstruction covers requests explicitly marked by the loop before freezing; direct one-shot LLM calls remain outside that marker contract even when callers freeze them or attach a session id. - Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 43216baa1e..2e6194b59b 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -162,27 +162,28 @@ export class InvariantService extends Service { throw new InvariantError(packageName, message) }) ) - const child = ctx.plugin(installer.inject === undefined - ? installInvariant - : Object.assign(installInvariant, { inject: installer.inject })) - try { - await child - } catch (error) { - try { - await child.dispose() - } finally { - registrations.delete(packageName) - } - throw error - } + const child = ctx.plugin(installer.inject === undefined + ? installInvariant + : Object.assign(installInvariant, { inject: installer.inject })) - return async () => { try { + await child + } catch (error) { await child.dispose() - } finally { - registrations.delete(packageName) + throw error } + + return async () => { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + } + } catch (error) { + registrations.delete(packageName) + throw error } }, `invariants.register(${JSON.stringify(packageName)})`) } catch (error) { diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index b190379302..9000fb8955 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -260,6 +260,28 @@ describe('InvariantService lifecycle', () => { expect(retry).toHaveBeenCalledOnce() }) + it('rolls back publication effects and ownership when child-fiber publication fails', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + let rejectPublication = true + const stopRejecting = ctx.on('internal/plugin', (fiber) => { + if (!rejectPublication || fiber.uid === null) return + rejectPublication = false + fiber.ctx.on('invariants-test/ping', leaked, { global: true }) + throw new Error('publication failed') + }) + + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {})) + await expect(Promise.resolve(failed)).rejects.toThrow('publication failed') + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + stopRejecting() + + const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {})) + await retry + await retry() + }) + it('joins asynchronous checks and rolls back their effects on failure', async () => { const { ctx } = await setup() const leaked = vi.fn() diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 54d777ac9a..787202c9d1 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -152,6 +152,15 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => .toContain('line 6: ctx.invariants.register must use the checked local install function') }) + it.each([ + 'export default { name, inject, apply }', + "export * as default from './probe.ts'", + ])('rejects a default export that would collapse the Loader namespace', (defaultExport) => { + const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n` + expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + .toContain('must not default-export; Loader must retain the companion namespace') + }) + it('accepts explained empty installers and rejects unexplained ones', () => { const explained = ` export const name = 'probe-invariant' diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 931f6ff588..21bc5931ec 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -210,6 +210,9 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + if (hasDefaultExport(sourceFile)) { + addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace') + } checkInstaller(owner, sourceFile, sourceText, violations) } @@ -314,6 +317,19 @@ function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean { }) } +function hasDefaultExport(sourceFile: ts.SourceFile): boolean { + return sourceFile.statements.some((statement) => { + if (ts.isExportAssignment(statement)) return true + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true + if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false + if (ts.isNamespaceExport(statement.exportClause)) { + return statement.exportClause.name.text === 'default' + } + return statement.exportClause.elements.some(element => element.name.text === 'default') + }) +} + /** Format violations for the command-line gate. */ export function formatPackageInvariantViolation( root: string, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b51e47cedc..582972f778 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ function ciPrimaryGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -248,6 +249,7 @@ function ciArtifactGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -295,6 +297,13 @@ function snapshotGate(): Gate { }) } +function builtPackageInvariantsGate(needs?: string[]): Gate { + return pnpmScript('built-package-invariants', 'verify-built-package-invariants', { + label: 'built package invariants', + ...needs === undefined ? {} : { needs }, + }) +} + function positiveIntArg(envName: string, flag: string): string[] { const raw = process.env[envName] if (raw === undefined || raw === '') return [] @@ -312,6 +321,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), + builtPackageInvariantsGate(options.artifactNeeds), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', ...artifactOptions, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index c16c9402b5..7a4f678be8 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' +import Loader from '@cordisjs/plugin-loader' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' import { - MANUAL_INVARIANT_TESTS, testInvariantCompanionPaths, testInvariantCompanions, + usesManualInvariantTree, } from './test-invariants.ts' declare module 'cordis' { @@ -51,9 +52,10 @@ describe('global test invariant host', () => { .toEqual(Object.keys(testInvariantCompanions).sort()) }) - it('executes each companion registration with its owning package name', async () => { + it('loads and executes every source companion through the real Loader shape', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() + const loader = Object.create(Loader.prototype) as Loader const register = vi.fn((_packageName: string, installer: InvariantInstaller) => { expect(typeof installer).toBe('function') return () => {} @@ -61,7 +63,13 @@ describe('global test invariant host', () => { const fakeContext = { invariants: { register } } as unknown as Context for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { const path = rawPath.replace(/^\.\.\//, '') - await companion.apply(fakeContext) + expect(companion.default, path).toBeUndefined() + const unwrapped = loader.unwrapExports(companion) as typeof companion + expect(unwrapped, path).toBe(companion) + expect(typeof unwrapped.name, path).toBe('string') + expect(unwrapped.inject, path).toContain('invariants') + expect(typeof unwrapped.apply, path).toBe('function') + await unwrapped.apply(fakeContext) const call = register.mock.calls.at(-1) if (call === undefined) throw new Error(`${path}: companion did not register`) registrations.set(path, call[0]) @@ -69,29 +77,11 @@ describe('global test invariant host', () => { expect(registrations).toEqual(owners) }) - it('limits manual composition to focused invariant topology tests', () => { - expect(MANUAL_INVARIANT_TESTS).toEqual([ - '/packages/support/invariants/tests/service.spec.ts', - '/packages/compact/compact/tests/invariant.spec.ts', - '/packages/context/time-context/tests/invariant.spec.ts', - '/packages/core/session/tests/invariant.spec.ts', - '/packages/core/agent/tests/invariant.spec.ts', - '/packages/core/scope/tests/invariant.spec.ts', - '/packages/core/agent-loop/tests/invariant.spec.ts', - '/packages/core/system-prompt/tests/invariant.spec.ts', - '/packages/core/tools/tests/invariant.spec.ts', - '/packages/fs/fs/tests/invariant.spec.ts', - '/packages/hooks/hook-protocol/tests/invariant.spec.ts', - '/packages/llm/llm/tests/invariant.spec.ts', - '/packages/llm/llm-retry/tests/invariant.spec.ts', - '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', - '/packages/subagent/subagent/tests/invariant.spec.ts', - '/packages/tasks/tasks/tests/invariant.spec.ts', - '/packages/todo/tool-todo/tests/invariant.spec.ts', - '/packages/ui/permission/tests/invariant.spec.ts', - '/packages/ui/user-approval/tests/invariant.spec.ts', - '/packages/workflow/workflow/tests/invariant.spec.ts', - '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', - ]) + it('recognizes focused invariant suites without a package inventory', () => { + expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true) + expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 1f218e16f9..a3c16f1241 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -21,6 +21,7 @@ declare global { export interface TestInvariantCompanion { readonly name: string readonly inject: readonly string[] + readonly default?: unknown apply(ctx: Context): Promise<() => void> } @@ -28,28 +29,9 @@ export interface TestInvariantCompanion { export const testInvariantCompanions: Readonly> = import.meta.glob('../packages/*/*/src/invariant.ts', { eager: true }) -/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */ -export const MANUAL_INVARIANT_TESTS = [ +/** Manual-topology suites whose names cannot follow the focused invariant convention. */ +const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ '/packages/support/invariants/tests/service.spec.ts', - '/packages/compact/compact/tests/invariant.spec.ts', - '/packages/context/time-context/tests/invariant.spec.ts', - '/packages/core/session/tests/invariant.spec.ts', - '/packages/core/agent/tests/invariant.spec.ts', - '/packages/core/scope/tests/invariant.spec.ts', - '/packages/core/agent-loop/tests/invariant.spec.ts', - '/packages/core/system-prompt/tests/invariant.spec.ts', - '/packages/core/tools/tests/invariant.spec.ts', - '/packages/fs/fs/tests/invariant.spec.ts', - '/packages/hooks/hook-protocol/tests/invariant.spec.ts', - '/packages/llm/llm/tests/invariant.spec.ts', - '/packages/llm/llm-retry/tests/invariant.spec.ts', - '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', - '/packages/subagent/subagent/tests/invariant.spec.ts', - '/packages/tasks/tasks/tests/invariant.spec.ts', - '/packages/todo/tool-todo/tests/invariant.spec.ts', - '/packages/ui/permission/tests/invariant.spec.ts', - '/packages/ui/user-approval/tests/invariant.spec.ts', - '/packages/workflow/workflow/tests/invariant.spec.ts', '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', ] as const @@ -66,7 +48,8 @@ const hosts = new WeakMap() const originalPlugin = RegistryService.prototype.plugin RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) { - if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack) + const testPath = expect.getState().testPath ?? '' + if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack) const root = this.ctx.root const host = hosts.get(root) ?? startInvariantHost(root) @@ -83,9 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge return joinInvariantStartup(fiber, host.ready) } -function usesManualInvariantTree(): boolean { - const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? '' - return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) +/** + * Detect focused suites that construct service selection or companion lifecycle explicitly. + * @param testPath - absolute or repo-relative Vitest file path. + * @returns whether the global invariant host must leave the root untouched. + */ +export function usesManualInvariantTree(testPath: string): boolean { + const normalized = testPath.replaceAll('\\', '/') + if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true + return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path)) } const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs new file mode 100644 index 0000000000..b696964f4f --- /dev/null +++ b/scripts/verify-built-package-invariants.mjs @@ -0,0 +1,51 @@ +/** Verify every compiled companion through its package self-reference under plain Node. */ + +import { spawnSync } from 'node:child_process' +import { globSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '..') +const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href +const failures = [] +const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() + +for (const manifestPath of manifests) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue + } + + const probe = ` + const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); + const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); + if ('default' in companion) throw new Error('companion has a default export'); + const loader = Object.create(Loader.prototype); + const unwrapped = loader.unwrapExports(companion); + if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); + if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); + if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { + throw new Error('companion does not inject invariants'); + } + if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); + ` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { + cwd: packageDir, + encoding: 'utf8', + }) + if (result.status === 0) continue + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) +} + +if (failures.length > 0) { + console.error('verify-built-package-invariants: compiled companion failures:') + for (const failure of failures) console.error(` ${failure}`) + process.exit(1) +} + +console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) From 9848fdf93e4cf35e905104152ad6a4eb377b3b7d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:25:51 +0800 Subject: [PATCH 36/48] refactor: remove unrelated helper extractions --- packages/sdk/scripts/src/args.ts | 5 ++-- packages/sdk/scripts/src/forwarding.ts | 21 ----------------- .../src/structured-protocol.ts | 10 -------- .../subagent-inprocess/src/structured.ts | 14 +++++++++-- .../support/agent-loop-testkit/src/index.ts | 18 ++++----------- packages/ui/app-boot/src/config-path.ts | 23 ------------------- packages/ui/app-boot/src/index.ts | 21 +++++++++++++++-- 7 files changed, 39 insertions(+), 73 deletions(-) delete mode 100644 packages/sdk/scripts/src/forwarding.ts delete mode 100644 packages/subagent/subagent-inprocess/src/structured-protocol.ts delete mode 100644 packages/ui/app-boot/src/config-path.ts diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 448315a0ec..4d1ce867de 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -6,7 +6,6 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' -import { splitForwardedArgs } from './forwarding.ts' /** Commands implemented by the dsh-sdk launcher. */ type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' @@ -35,7 +34,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { return { forwarded: [], help: true } } - const { launcher: launcherArgv, forwarded: passthrough } = splitForwardedArgs(argv) + const separator = argv.indexOf('--') + const launcherArgv = separator === -1 ? argv : argv.slice(0, separator) + const passthrough = separator === -1 ? [] : argv.slice(separator + 1) let parsed: DshSdkArgs | undefined const program = new Command() .name('dsh-sdk') diff --git a/packages/sdk/scripts/src/forwarding.ts b/packages/sdk/scripts/src/forwarding.ts deleted file mode 100644 index 56fa85f564..0000000000 --- a/packages/sdk/scripts/src/forwarding.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Argument-delimiter handling shared by the SDK launcher and its invariant. */ - -/** Launcher-owned arguments and opaque arguments following `--`. */ -export interface ForwardedArgumentSplit { - /** Arguments parsed by the SDK launcher. */ - readonly launcher: readonly string[] - /** Arguments passed unchanged to the selected project command. */ - readonly forwarded: readonly string[] -} - -/** - * Split the first `--` delimiter without interpreting either side. - * @param argv - complete user argument vector. - * @returns launcher arguments and post-delimiter arguments. - */ -export function splitForwardedArgs(argv: readonly string[]): ForwardedArgumentSplit { - const separator = argv.indexOf('--') - return separator === -1 - ? { launcher: argv, forwarded: [] } - : { launcher: argv.slice(0, separator), forwarded: argv.slice(separator + 1) } -} diff --git a/packages/subagent/subagent-inprocess/src/structured-protocol.ts b/packages/subagent/subagent-inprocess/src/structured-protocol.ts deleted file mode 100644 index 8ae87e73b0..0000000000 --- a/packages/subagent/subagent-inprocess/src/structured-protocol.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Model-facing constants shared by structured child execution and its invariant. */ - -/** The model-facing tool name a structured child must call to finish. */ -export const STRUCTURED_OUTPUT_TOOL = 'structured_output' - -/** The terminal structured-result instruction appended to a child request. */ -export const STRUCTURED_OUTPUT_INSTRUCTION - = 'When you have your final answer, you MUST report it by calling the ' - + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` - + 'Do not finish with a plain text answer: only the tool call counts as your result.' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index cecb7d173e..09aa2d24b7 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -15,9 +15,19 @@ import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' -import { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' -export { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' +/** The model-facing tool name a structured child must call to finish. */ +export const STRUCTURED_OUTPUT_TOOL = 'structured_output' + +/** + * The instruction registered as the child's trailing (order-190, the end of + * the tool-guidance band) scoped prompt section: the demand travels with the + * tool, as ordinary prompt state of exactly one agent. + */ +export const STRUCTURED_OUTPUT_INSTRUCTION + = 'When you have your final answer, you MUST report it by calling the ' + + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + + 'Do not finish with a plain text answer: only the tool call counts as your result.' /** One structured run's live handle: read the captured value once the child settles. */ export interface StructuredAttachment { diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index ca7148ee23..c7b0cb7304 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -6,7 +6,12 @@ */ import type { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config as ToolRegistryConfig } from '@deepseek-ai/dsh-tools' /** Configuration forwarded to the prerequisite service plugins. */ @@ -33,19 +38,6 @@ export async function mountAgentLoopTestDependencies( ctx: Context, options: AgentLoopTestDependenciesOptions = {}, ): Promise { - const [ - { default: LlmService }, - { default: SessionStore }, - { default: SystemPrompt }, - { default: ToolRegistry }, - { default: AgentRegistry }, - ] = await Promise.all([ - import('@deepseek-ai/dsh-llm'), - import('@deepseek-ai/dsh-session'), - import('@deepseek-ai/dsh-system-prompt'), - import('@deepseek-ai/dsh-tools'), - import('@deepseek-ai/dsh-agent'), - ]) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) diff --git a/packages/ui/app-boot/src/config-path.ts b/packages/ui/app-boot/src/config-path.ts deleted file mode 100644 index bdfb933feb..0000000000 --- a/packages/ui/app-boot/src/config-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Snapshot-aware application configuration path selection. @module @deepseek-ai/dsh-app-boot/config-path */ - -import { basename, dirname, resolve } from 'node:path' - -/** - * Resolve the config to boot. Replay swaps a `cordis.yml` basename for - * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. - * @param configPath - requested config path, absolute or relative to `cwd`. - * @param snapshotMode - bin `$DSH_SNAPSHOT`; only `replay` swaps the basename. - * @param cwd - base for a relative `configPath`. - * @returns the absolute path of the config to boot. - */ -export function resolveConfigPath( - configPath: string, - snapshotMode: string | undefined, - cwd: string = process.cwd(), -): string { - const absolute = resolve(cwd, configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 959a7120d7..e2413fa736 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,12 +6,29 @@ */ import { pathToFileURL } from 'node:url' -import { dirname, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' -export { resolveConfigPath } from './config-path.ts' +/** + * Resolve the config to boot. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. + * @param configPath - the requested config path (absolute, or relative to `cwd`). + * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the + * basename. + * @param cwd - the base a relative `configPath` resolves against. + * @returns the absolute path of the config to boot. + */ +export function resolveConfigPath( + configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the From d50e6d4b8e593f0459af77cb11ea31ee356caabf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:38:20 +0800 Subject: [PATCH 37/48] test(invariants): include goal session topology --- scripts/test-invariants.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index bc2e6835e4..a83f9c8676 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -82,6 +82,7 @@ describe('global test invariant host', () => { '/packages/core/tools/tests/invariant.spec.ts', '/packages/fs/fs/tests/invariant.spec.ts', '/packages/goal/goal/tests/invariant.spec.ts', + '/packages/goal/goal-session/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', '/packages/llm/llm-retry/tests/invariant.spec.ts', From 6fc7dd4c02b98dc815f0dbd5d2fe79473873f3e9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 21 Jul 2026 11:07:50 +0800 Subject: [PATCH 38/48] chore(gates): route doc-sync through the run-gates scheduler pnpm run doc-sync was a serial &&-chain of 24 pnpm run subcommands; the 24 script bodies finish in ~34s while the chain takes ~3min, all wrapper overhead. Add a doc-sync mode to scripts/run-gates.ts expanding to docSyncLeafGates() (concurrency capped at 4 like pre-push: several doc gates each build a full ts.Program) and point the package script at it, making the leaf list the single source of truth for the member set. Consolidating the two lists surfaced drift: verify-cordis-api joined the package.json chain with the runtime API catalog but was never added to docSyncLeafGates, so CI and pre-push never gated that catalog's freshness. Add the missing leaf. --- .../2026-07-06-parallel-pre-push-gates.md | 2 +- ...-doc-sync-through-gate-scheduler.i18n.yaml | 6 +++++ ...6-07-21-doc-sync-through-gate-scheduler.md | 25 +++++++++++++++++++ ...7-21-doc-sync-through-gate-scheduler.zh.md | 25 +++++++++++++++++++ AGENTS.md | 2 +- docs/development.i18n.yaml | 4 +-- docs/development.md | 2 +- docs/development.zh.md | 2 +- package.json | 2 +- scripts/run-gates.ts | 16 +++++++++--- 10 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md create mode 100644 .agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 60472e51ec..710c37cb3a 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -20,7 +20,7 @@ The build gate makes the hook self-contained from a clean worktree. `publint`, ` [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. -The aggregate package scripts remain the source of truth for ad hoc local runs. The scheduler is a parallel execution plan over their member gates, not a replacement vocabulary. +The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)). ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml new file mode 100644 index 0000000000..9deebd1da3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml @@ -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 +2026-07-21-doc-sync-through-gate-scheduler.md: b79df2dd7d3515cb0434ac672f7f87c3271d900b +2026-07-21-doc-sync-through-gate-scheduler.zh.md: 9395244e3c7700166ad87c49219073210c66bc7e diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md new file mode 100644 index 0000000000..b79df2dd7d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md @@ -0,0 +1,25 @@ +# Agent Note: doc-sync through the gate scheduler + +Status: implemented + +English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md) + +## Problem + +`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI and pre-push never enforced that catalog's freshness. + +## Decision + +`doc-sync` in `package.json` now delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — the same way `check:pre-push` and the `check:ci:*` scripts already do ([parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The new `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set; the chain that could drift from it is gone. Like `pre-push`, the mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. + +The drift this consolidation surfaced is fixed in the same change: `docSyncLeafGates` gains the missing `verify-cordis-api` leaf, so CI and pre-push now gate the generated runtime API catalog alongside the other generated docs. + +## Alternatives considered + +- **Keep the `&&` chain and only fix the missing leaf** — repairs today's drift but keeps two member lists that will drift again, and keeps the 24 serial pnpm wrapper starts. +- **A dedicated `scripts/doc-sync.ts` importing each verify module in one process** — saves even the per-gate tsx boot, but requires refactoring all 24 scripts from run-at-import to callable entry points and loses the scheduler's per-gate timing, isolation, and failure grouping; the wrapper start the scheduler already avoids is the dominant cost. +- **Shell loop over `tsx scripts/*.ts`** — avoids pnpm wrapper starts cheaply but adds a second execution vocabulary next to the scheduler CI already uses, with none of its scheduling or reporting. + +## Consequences + +One `pnpm run doc-sync` now costs one pnpm wrapper start plus the slowest dependency chain of member gates instead of 24 wrapper starts plus the sum of all members. Adding a doc gate is one edit in `docSyncLeafGates` (plus the package script itself for ad hoc runs); `package.json` keeps the per-gate `verify-*` scripts as the vocabulary for running one gate by hand. The scheduler prints per-gate timing, so a slow doc-sync points at the gate that dominates. `pnpm run doc-sync` output is now interleaved scheduler output rather than sequential per-command output; anything parsing that output must key on the `run-gates:` summary lines. diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md new file mode 100644 index 0000000000..9395244e3c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md @@ -0,0 +1,25 @@ +# Agent Note: doc-sync 走门禁调度器 + +Status: implemented + +[English](2026-07-21-doc-sync-through-gate-scheduler.md) | 中文 + +## 问题 + +`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 和 pre-push 从未把关该目录的新鲜度。 + +## 决策 + +`package.json` 中的 `doc-sync` 现在委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与 `check:pre-push` 和各 `check:ci:*` 脚本的做法一致([并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。新增的 `doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源;那条可能与之漂移的链不复存在。与 `pre-push` 一样,该模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 + +这次整合暴露出的漂移在同一变更中修复:`docSyncLeafGates` 补上缺失的 `verify-cordis-api` 叶子,CI 和 pre-push 从此与其他生成文档一起把关生成的运行时 API 目录。 + +## 考虑过的替代方案 + +- **保留 `&&` 链,只补缺失的叶子**——能修好今天的漂移,但保留了两份还会再漂移的成员列表,也保留了 24 次串行的 pnpm 包装层启动。 +- **专门的 `scripts/doc-sync.ts` 在单进程内 import 各校验模块**——连每个门禁的 tsx 启动也能省掉,但需要把全部 24 个脚本从 import 即执行改造成可调用入口,还会失去调度器的按门禁计时、隔离和失败分组;而调度器已经避免的包装层启动才是开销的大头。 +- **用 shell 循环跑 `tsx scripts/*.ts`**——以低成本避开 pnpm 包装层启动,却在 CI 已经使用的调度器旁边增加了第二套执行词汇,且没有它的任何调度与报告能力。 + +## 结果 + +一次 `pnpm run doc-sync` 的成本从 24 次包装层启动加全部成员之和,变为一次包装层启动加成员门禁中最慢的依赖链。新增文档门禁只需在 `docSyncLeafGates` 改一处(外加 package script 本身以便手工单独运行);`package.json` 保留各 `verify-*` 脚本作为手工运行单个门禁的词汇。调度器输出按门禁计时,doc-sync 变慢时能直接指向占大头的门禁。`pnpm run doc-sync` 的输出从逐命令顺序输出变为调度器的交错输出;解析该输出的工具必须以 `run-gates:` 摘要行为准。 diff --git a/AGENTS.md b/AGENTS.md index 87f46c88a0..5655c89cb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check -pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json +pnpm run doc-sync # all documentation gates; see the doc-sync leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as the site's dead-link check) pnpm run demo:echo # mock-model REPL, no key needed pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ca3f14fbc..c3e1f4d2f7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 94eb4f03329b574862a1ac1de2f8c1d4db4f4a0a -development.zh.md: b533aff43a66ff7cfc5dc61e5b9b224a01c51f12 +development.md: 1b8d94d281910277764be0e2693e54b537112e9a +development.zh.md: 396252e5cd8350f1cf7f987a1e9a943ed3145286 diff --git a/docs/development.md b/docs/development.md index 94eb4f0332..1b8d94d281 100644 --- a/docs/development.md +++ b/docs/development.md @@ -90,7 +90,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling -pnpm run doc-sync # all Markdown/doc gates; see the doc-sync script in package.json for the full list +pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files diff --git a/docs/development.zh.md b/docs/development.zh.md index b533aff43a..396252e5cd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -90,7 +90,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling -pnpm run doc-sync # all Markdown/doc gates; see the doc-sync script in package.json for the full list +pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files diff --git a/package.json b/package.json index ad4704bf98..812b11cbd5 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "website:dev": "pnpm --filter @deepseek-ai/website run dev", "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml", + "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 0262c1099c..7113444700 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -19,6 +19,7 @@ type Mode = | 'ci-artifacts' | 'node-compat' | 'pre-push' + | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' interface Gate { @@ -88,21 +89,25 @@ function parseMode(raw: string | undefined): Mode { case 'ci-artifacts': case 'node-compat': case 'pre-push': + case 'doc-sync': return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { const available = availableParallelism() - const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available + // Local modes cap workers: several doc gates each build a full ts.Program, + // so an uncapped default on a large host trades wall clock for memory blowups. + const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync' + const modeLimit = localCap ? Math.min(4, available) : available return { workers: Math.min(total, modeLimit), - source: selectedMode === 'pre-push' - ? `${available} available CPU(s), pre-push cap 4` + source: localCap + ? `${available} available CPU(s), ${selectedMode} cap 4` : `${available} available CPU(s)`, } } @@ -197,6 +202,8 @@ function gatesForMode(selected: Mode): Gate[] { }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] + case 'doc-sync': + return docSyncLeafGates() } } @@ -331,6 +338,7 @@ function docSyncLeafGates(options: { return [ pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), + pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), From 2fd995bf3c838197b838b592cdc1133d3396f8ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:29:40 +0800 Subject: [PATCH 39/48] fix(lsp): align operations and harden lifecycle --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 26 ++--- .../2026-07-15-lsp-capability-seam.zh.md | 28 +++--- AGENTS.md | 1 + docs/config-catalog.md | 10 +- docs/core-data-structures/lsp.md | 17 ++-- docs/module-graph.md | 3 +- docs/tool-catalog.md | 10 +- .../acp-agent/tests/lsp.cordis.snapshot.yml | 2 + examples/acp-agent/tests/lsp.cordis.yml | 2 + .../snapshots/lsp-definition/session.jsonl | 8 +- .../lsp-definition/stdout.expected.jsonl | 2 +- .../lsp-definition/system-prompt.expected.md | 2 +- .../lsp-definition/tool-schemas.expected.json | 10 +- knip.json | 2 +- packages/lsp/README.md | 2 +- packages/lsp/lsp-local/README.md | 10 +- packages/lsp/lsp-local/package.json | 2 +- packages/lsp/lsp-local/src/abort.ts | 48 +++++++++ packages/lsp/lsp-local/src/connection.ts | 66 ++++++++----- packages/lsp/lsp-local/src/host.ts | 28 +++++- packages/lsp/lsp-local/src/index.ts | 90 +++++++++++------ packages/lsp/lsp-local/src/instance.ts | 82 ++++++--------- packages/lsp/lsp-local/src/translate.ts | 67 +++++++++---- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 8 +- .../lsp/lsp-local/tests/connection.spec.ts | 21 ++-- .../lsp/lsp-local/tests/fixture-server.ts | 33 +++++-- packages/lsp/lsp-local/tests/host.spec.ts | 18 ++++ packages/lsp/lsp-local/tests/instance.spec.ts | 63 +++++++----- .../lsp/lsp-local/tests/lifecycle.spec.ts | 99 +++++++++++++------ packages/lsp/lsp-local/tests/provider.spec.ts | 27 ++++- .../lsp/lsp-local/tests/translate.spec.ts | 44 ++++++--- .../lsp-local/tests/typescript-server.e2e.ts | 6 +- packages/lsp/lsp/README.md | 4 +- packages/lsp/lsp/src/index.ts | 8 +- packages/lsp/lsp/src/types.ts | 13 +-- packages/lsp/lsp/tests/lsp.spec.ts | 2 +- packages/lsp/tool-lsp/README.md | 10 +- packages/lsp/tool-lsp/package.json | 4 +- packages/lsp/tool-lsp/src/index.ts | 48 +++++---- packages/lsp/tool-lsp/src/render.ts | 40 +++++--- .../lsp/tool-lsp/tests/integration.spec.ts | 4 +- packages/lsp/tool-lsp/tests/render.spec.ts | 37 ++++--- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 29 ++++-- packages/lsp/tool-lsp/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 46 files changed, 680 insertions(+), 366 deletions(-) create mode 100644 packages/lsp/lsp-local/src/abort.ts diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 097eb8134e..82433d5fff 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 6a71858bb6c8ca0ad422042a22ee9085387db46d -2026-07-15-lsp-capability-seam.zh.md: 19a00a762d4f096f02386cf4e4c09e62daee5d6d +2026-07-15-lsp-capability-seam.md: 91b15e8f9ff044d2c438c87040f9fc19a8dabc6a +2026-07-15-lsp-capability-seam.zh.md: 39e63370241e8bbeb93ea7bb81fbd951fe807b19 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 6a71858bb6..91b15e8f9f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -22,7 +22,7 @@ Add LSP as a three-package capability seam with one read-only model tool and one `dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. -The model and seam expose exactly `definition`, `references`, `implementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. +The model and seam expose exactly `goToDefinition`, `findReferences`, `goToImplementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. These operation literals match Claude Code's familiar camelCase names while the tool name and `file_path` field remain harness-owned. The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` @@ -30,14 +30,14 @@ The prompt positions LSP as a precision aid: `Use search/read for ordinary navig `dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities. -The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` validates, selects, and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. +The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. The intended contract shape is: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' type LspProviderId = Branded<'LspProviderId'> interface LspPosition { @@ -77,7 +77,7 @@ interface LspService { } ``` -Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. `dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. @@ -87,18 +87,18 @@ The single `lsp` tool accepts: ```ts interface LspToolInput { - readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' readonly file_path: string readonly line: number readonly character: number } ``` -`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input. +`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input. The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace. -Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100`, and `maxHoverChars` defaults to `16_000` after hover normalization; both report omissions. Empty locations and `null` hover are successful no-result responses; malformed payloads remain structured errors. +Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors. ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure. @@ -108,11 +108,11 @@ ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_pat The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. -Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) before hard kill; the same bounds govern failed-instance cleanup. It uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. +Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. ## Workspace, filesystem, and document synchronization -`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one handle through validation and reading. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. +`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers. @@ -123,7 +123,7 @@ The local provider uses a compatibility-first transient-open sequence for every 3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. 4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. -Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-instance queue serializes complete lifecycles; distinct instances may run in parallel. The server's workspace index remains responsible for closed files reached from the source. +Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source. The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider. @@ -133,7 +133,7 @@ The canonical workspace `realpath` must be a directory and supplies process cwd, Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. -Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Hover normalization takes `MarkupContent.value`, preserves string `MarkedString` values, renders language-tagged values as fenced code, joins arrays with one blank line, and applies `maxHoverChars` last. +Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering. Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. @@ -176,10 +176,10 @@ The local provider trusts its configured server and claims no sandbox confinemen - Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. - Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. -- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `references.includeDeclaration`. +- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. - Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. - Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. -- Lifecycle tests pin startup single-flight, per-instance serialization, cross-instance parallelism, abortable queues, crash replacement without replay, and quiescent disposal. +- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. - Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. - A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping. - Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 19a00a762d..39e6337024 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -22,22 +22,22 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 `dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 -模型与服务边界仅公开 `definition`、`references`、`implementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。 +模型与服务边界仅公开 `goToDefinition`、`findReferences`、`goToImplementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。 提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` -## Package 与职责边界 +## 包与职责边界 `dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。 -服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行校验、选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 +服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 预期契约如下: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' type LspProviderId = Branded<'LspProviderId'> interface LspPosition { @@ -77,7 +77,7 @@ interface LspService { } ``` -映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 `dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 @@ -87,18 +87,18 @@ interface LspService { ```ts interface LspToolInput { - readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' readonly file_path: string readonly line: number readonly character: number } ``` -`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`references` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 +`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 -位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,`maxHoverChars` 在 `hover` 归一化后应用,默认值为 `16_000`;两者都会报告省略数量。空位置与 `null` hover 是成功的无结果响应;格式错误的载荷保持为结构化错误。 +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 @@ -108,11 +108,11 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 -提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`)限制强制终止前的宽限期;失败实例的清理也使用相同边界。它使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 +提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 ## 工作区、文件系统与文档同步 -`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 +`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 `read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 @@ -123,7 +123,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 -每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个实例使用一个可取消队列串行执行完整生命周期;不同实例可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 +每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消,并串行执行源文件读取、打开、查询和关闭的完整生命周期,因此等待中的查询只在轮到它时才读取当前字节;实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。 @@ -133,7 +133,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 -导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。`hover` 归一化直接采用 `MarkupContent.value`,保留字符串 `MarkedString`,把带语言标签的值渲染为围栏代码块,以一个空行连接数组,并在最后应用 `maxHoverChars`。 +导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。 取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。 @@ -176,10 +176,10 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 -- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 +- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 - 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 - 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 -- 生命周期测试固定启动 single-flight、实例内串行、跨实例并行、可取消队列、崩溃后不重放的替换,以及释放后完全停稳。 +- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 - 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 diff --git a/AGENTS.md b/AGENTS.md index 6a0e4ca13b..5835149048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools + lsp/ language-server seam + local stdio provider + model-facing lsp tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8c7abf7d14..1d7930a84e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -650,12 +650,12 @@ export interface LspLocalServerConfig { maxDocumentBytes?: number /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:83`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -1188,14 +1188,14 @@ Requires: `tools` · `lsp` · `systemPrompt` export interface Config { /** Largest number of rendered locations before an omission marker (default 100). */ maxLocations?: number - /** Largest hover length in characters after normalization (default 16000). */ - maxHoverChars?: number + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number /** Tool-call timeout budget in ms (default 60000). */ timeoutMs?: number } ``` -Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts) +Source: [`packages/lsp/tool-lsp/src/index.ts:58`](../packages/lsp/tool-lsp/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index 6bf63c20c2..eb370f6e38 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -14,7 +14,7 @@ The seam and model expose exactly four semantic queries; the union is closed, so * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are * deliberately deferred (they need different schemas). */ -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' ``` ```ts type-equiv @@ -71,7 +71,7 @@ interface LspProviderQuery extends LspQueryRequest { ## Result -A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `findReferences` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. ```ts type-equiv /** One resolved location: a document URI and the range within it. */ @@ -95,9 +95,9 @@ interface LspHover { ```ts type-equiv /** - * The closed result union. Navigation operations (`definition`, `references`, `implementation`) - * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` - * to exhaustiveness so a new arm breaks compilation until handled. + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. * * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that @@ -116,8 +116,9 @@ A provider owns a stable branded `id` and an exclusive lowercase leading-dot ext ```ts type-equiv /** * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link - * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` - * always includes declarations — the provider enforces this internally; callers get no flag. + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. */ interface LspProvider { /** Stable provider identity, reserved atomically with the extension mappings. */ @@ -159,4 +160,4 @@ interface LspService { } ``` -`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with a stable `code` (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`) callers route on instead of parsing `message`. +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bbfe9c93d..715ac67732 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -404,6 +404,7 @@ flowchart TD pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp pkg_tool_lsp --> pkg_system_prompt + pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools @@ -610,7 +611,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | -| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 326f6c02a7..8273203616 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -492,7 +492,7 @@ create, edit, pause, and resume require direct-human root authority; complete an ### `lsp` -Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration. +Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration. ```json { @@ -500,11 +500,11 @@ Query a language server for precise code navigation. operation is one of definit "properties": { "operation": { "type": "string", - "description": "definition, references, implementation, or hover.", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", "enum": [ - "definition", - "references", - "implementation", + "goToDefinition", + "findReferences", + "goToImplementation", "hover" ] }, diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml index 4d24ead86d..dc672376b5 100644 --- a/examples/acp-agent/tests/lsp.cordis.snapshot.yml +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -19,6 +19,8 @@ args: ['./lsp-server.mjs'] extensionToLanguage: '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' - id: tool-lsp name: '@deepseek-ai/dsh-tool-lsp' config: diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml index f1eefa99af..49c9099d65 100644 --- a/examples/acp-agent/tests/lsp.cordis.yml +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -17,6 +17,8 @@ args: ['./lsp-server.mjs'] extensionToLanguage: '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' - id: tool-lsp name: '@deepseek-ai/dsh-tool-lsp' config: diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index 36fb9ad8f2..00780e7787 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -4,12 +4,12 @@ {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} {"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl index 9e527ea2c5..ce84702157 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl @@ -1,7 +1,7 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP definition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP goToDefinition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index ad82d538b7..8e49c2dce5 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -15,7 +15,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration. +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 1105e030c6..4fa5010b72 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -117,17 +117,17 @@ }, { "name": "lsp", - "description": "Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.", + "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", "parameters": { "type": "object", "properties": { "operation": { "type": "string", - "description": "definition, references, implementation, or hover.", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", "enum": [ - "definition", - "references", - "implementation", + "goToDefinition", + "findReferences", + "goToImplementation", "hover" ] }, diff --git a/knip.json b/knip.json index 8565e5b462..e80eb596e5 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], - "ignore": ["examples/*/tests/snapshots/*/workspace/**/*"], "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { @@ -14,6 +13,7 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 12e8722671..147888a259 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -8,6 +8,6 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | | `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | -The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. +The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 35f109620a..66568b17d1 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -9,7 +9,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. -- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. +- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration @@ -28,13 +28,13 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v | `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | | `maxDocumentBytes` | `4000000` | Largest source file this host will open. | | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | -| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | +| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | -`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. +`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. ## Protocol behavior -Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors. ## Security boundary @@ -50,6 +50,6 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes. ## Known Limitations and Deferred Work -- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. - **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index d437e91109..68e6cf7764 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-lsp-local", - "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace", + "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/lsp/lsp-local/src/abort.ts b/packages/lsp/lsp-local/src/abort.ts new file mode 100644 index 0000000000..7790069e44 --- /dev/null +++ b/packages/lsp/lsp-local/src/abort.ts @@ -0,0 +1,48 @@ +/** + * Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases. + * @module @deepseek-ai/dsh-lsp-local/abort + */ + +import { timeoutOf } from '@deepseek-ai/dsh-timeout' + +/** + * Build an abort Error carrying the signal's reason and preserving timeout classification. + * @param signal - the aborted signal whose reason to surface. + * @returns the timeout reason if present, else the Error reason, else a generic aborted Error. + */ +export function abortError(signal: AbortSignal): Error { + const timeout = timeoutOf(signal) + if (timeout !== undefined) return timeout + const reason: unknown = signal.reason + if (reason instanceof Error) return reason + return new Error('LSP query aborted') +} + +/** + * Throw the signal's classified abort error when it has already fired. + * @param signal - the optional query cancellation signal. + */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(signal) +} + +/** + * Await work while allowing a query signal to abandon its wait; the underlying work keeps its own + * handlers and continues to its owner-defined quiescence boundary. + * @param work - the owned asynchronous work. + * @param signal - optional query cancellation. + * @returns the work result, or a rejection carrying the classified abort reason. + */ +export function abortable(work: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return work + if (signal.aborted) return Promise.reject(abortError(signal)) + const canceled = Promise.withResolvers() + const onAbort = (): void => { canceled.reject(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + const normalized = work.catch((error: unknown) => { + /* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */ + throw error instanceof Error ? error : new Error(String(error)) + }) + return Promise.race([normalized, canceled.promise]) + .finally(() => { signal.removeEventListener('abort', onAbort) }) +} diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 6acb321e90..ae725b56f7 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -75,10 +75,10 @@ export class LspConnection { }) }) this.child.on('error', (error) => { this.fail(error) }) - // A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE - // during teardown does not crash the process. Pending requests fail via the 'close' handler. - /* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */ - this.child.stdin.on('error', () => { /* swallow */ }) + // Child stdin can fail while the process itself remains alive (for example, a server closes fd + // 0). Treat that as a fatal connection error so pending requests reject immediately instead of + // waiting for a process-close event that may never arrive. + this.child.stdin.on('error', (error) => { this.fail(error) }) this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) } @@ -108,15 +108,9 @@ export class LspConnection { return } this.pending.set(id, { resolve, reject }) - try { - this.write({ jsonrpc: '2.0', id, method, params }) - } catch (error) { - /* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed - 'error' listener, so this synchronous catch is a defensive guard. */ - this.pending.delete(id) - reject(asError(error)) - /* v8 ignore stop */ - } + // `write()` records either synchronous or callback-delivered failures on the connection and + // rejects every pending request. This handler only consumes the write promise itself. + void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {}) }) // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled @@ -129,9 +123,10 @@ export class LspConnection { * Send a notification (no id, no response). * @param method - the JSON-RPC method. * @param params - the notification params. + * @returns a promise that settles when the framed notification has been written. */ - notify(method: string, params: unknown): void { - this.write({ jsonrpc: '2.0', method, params }) + notify(method: string, params: unknown): Promise { + return this.write({ jsonrpc: '2.0', method, params }) } /** @@ -139,11 +134,9 @@ export class LspConnection { * @param requestId - the numeric id of the request to cancel. */ cancel(requestId: number): void { - try { - this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }) - } catch { - // The server is already gone or unwritable; the pending request will fail on close. - } + // The server is already gone or unwritable when this rejects; `write()` has recorded the fatal + // connection failure and rejected the pending request, so cancellation remains best-effort. + void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {}) } /** @@ -253,7 +246,10 @@ export class LspConnection { const id = frame.id const method = frame.method if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) { - void this.handleServerRequest(id, method, frame.params) + // A response-write failure has already invalidated the connection in `write()`. + /* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection + failure makes this consumption handler run. */ + void this.handleServerRequest(id, method, frame.params).catch(() => {}) return } if (typeof method === 'string') { @@ -266,9 +262,9 @@ export class LspConnection { private async handleServerRequest(id: number | string, method: string, params: unknown): Promise { try { const result = await this.onServerRequest(method, params) - this.write({ jsonrpc: '2.0', id, result }) + await this.write({ jsonrpc: '2.0', id, result }) } catch (error) { - this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) + await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) } } @@ -285,8 +281,28 @@ export class LspConnection { pending.resolve(frame.result) } - private write(message: unknown): void { - this.child.stdin.write(encodeMessage(message)) + private write(message: unknown): Promise { + if (this.closeReason !== undefined) return Promise.reject(this.closeReason) + return new Promise((resolve, reject) => { + const done = (error?: Error | null): void => { + if (error === undefined || error === null) { + resolve() + return + } + this.fail(error) + reject(error) + } + try { + this.child.stdin.write(encodeMessage(message), done) + /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a + nonconforming Writable implementation throwing synchronously. */ + } catch (error) { + const failure = asError(error) + this.fail(failure) + reject(failure) + } + /* v8 ignore stop */ + }) } /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index 949a1b838b..11996908ac 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -13,6 +13,7 @@ import { constants } from 'node:fs' import { open, realpath, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' +import { throwIfAborted } from './abort.ts' /** A validated source: its canonical absolute path and current UTF-8 text. */ export interface HostSource { @@ -27,17 +28,21 @@ export interface HostSource { * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots * collapse to one instance. * @param workspaceRoot - the caller's workspace root (absolute). + * @param signal - optional cancellation observed around each filesystem operation. * @returns the canonical directory path. * @throws Error when the path is missing or not a directory. */ -export async function canonicalizeWorkspace(workspaceRoot: string): Promise { +export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise { + throwIfAborted(signal) let canonical: string try { canonical = await realpath(workspaceRoot) } catch (error) { throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`) } + throwIfAborted(signal) const info = await stat(canonical) + throwIfAborted(signal) if (!info.isDirectory()) { throw new Error(`workspace root "${workspaceRoot}" is not a directory`) } @@ -52,6 +57,7 @@ export async function canonicalizeWorkspace(workspaceRoot: string): Promise { + throwIfAborted(signal) const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath) let canonicalPath: string try { @@ -67,6 +75,7 @@ export async function readHostSource( } catch (error) { throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`) } + throwIfAborted(signal) if (!isInside(canonicalWorkspace, canonicalPath)) { throw new Error(`source "${filePath}" resolves outside the workspace`) } @@ -74,9 +83,12 @@ export async function readHostSource( // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we // actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a // symlink between realpath and open (which would otherwise escape the workspace). - const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW) + // O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular. + const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) try { + throwIfAborted(signal) const info = await handle.stat() + throwIfAborted(signal) if (!info.isFile()) { throw new Error(`source "${filePath}" is not a regular file`) } @@ -85,8 +97,9 @@ export async function readHostSource( } // Bound the read to the cap even if the file grew after stat: read one extra byte and reject on // overflow, so a concurrent grow cannot defeat the memory bound. - const buffer = await readCapped(handle, maxDocumentBytes, filePath) + const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal) const text = decodeUtf8Strict(buffer, filePath) + throwIfAborted(signal) return { canonicalPath, text } } finally { await handle.close() @@ -94,12 +107,19 @@ export async function readHostSource( } /** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */ -async function readCapped(handle: FileHandle, maxBytes: number, filePath: string): Promise { +async function readCapped( + handle: FileHandle, + maxBytes: number, + filePath: string, + signal?: AbortSignal, +): Promise { const limit = maxBytes + 1 const chunk = Buffer.allocUnsafe(limit) let total = 0 for (;;) { + throwIfAborted(signal) const { bytesRead } = await handle.read(chunk, total, limit - total, total) + throwIfAborted(signal) if (bytesRead === 0) break total += bytesRead /* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */ diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 67e86da11c..095d761624 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -15,14 +15,16 @@ import { accessSync, constants, statSync } from 'node:fs' import { delimiter, isAbsolute, join } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' -import { LspProviderId } from '@deepseek-ai/dsh-lsp' +import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' import type { LspProvider, LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' import { canonicalizeWorkspace, readHostSource } from './host.ts' -import { abortError, LspInstance } from './instance.ts' +import { LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -75,7 +77,7 @@ export interface LspLocalServerConfig { maxDocumentBytes?: number /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } @@ -98,8 +100,8 @@ const LspLocalServerConfig: z = z.object({ maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES), maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES), maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES), - shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS), - killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), + shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS), }) export const Config: z = z.object({ @@ -148,8 +150,8 @@ export function apply(ctx: Context, config: Config): void { function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. - assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs) + assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertTimer(providerId, 'killGraceMs', resolved.killGraceMs) // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad // document cap fails later in the read path instead of at load. @@ -158,6 +160,13 @@ function validateServerConfig(providerId: string, resolved: ResolvedServerConfig assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) } +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(providerId: string, name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ function assertPositiveInteger(providerId: string, name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { @@ -171,6 +180,8 @@ class LocalLspProvider implements LspProvider { readonly extensionToLanguage: Readonly> /** One live instance per canonical workspace realpath. */ private readonly instances = new Map() + /** One complete source-read→open→query→close serialization tail per canonical workspace. */ + private readonly queues = new Map>() private disposed = false constructor( @@ -192,35 +203,49 @@ class LocalLspProvider implements LspProvider { private assertActive(signal?: AbortSignal): void { /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls exercise the post-await check instead. */ - if (this.isDisposed()) throw new Error('lsp-local provider is disposed') + if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED') if (signal?.aborted) throw abortError(signal) } async query(request: LspProviderQuery, signal?: AbortSignal): Promise { // Honor an already-aborted signal before host I/O so a canceled request never starts a server. this.assertActive(signal) - const workspace = await canonicalizeWorkspace(request.workspaceRoot) - // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized - // source must fail without leaving an idle process pooled (the pre-start rejection contract), and - // the single-handle read preserves the containment/size checks against a mid-read swap. - const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) - // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we - // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. + const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal) this.assertActive(signal) - let instance = this.instanceFor(workspace) - // Eviction and replacement are synchronous so disposal cannot snapshot the pool between them and - // miss a newly spawned process. - if (instance.dead) { - this.evictIfCurrent(workspace, instance) - instance = this.instanceFor(workspace) - } - try { - return await instance.query(request, source, signal) - } finally { - // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, - // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) this.evictIfCurrent(workspace, instance) - } + return this.enqueue(workspace, signal, async () => { + this.assertActive(signal) + // Read inside the workspace queue but before spawning: a queued query sees current bytes when + // its turn starts, while an invalid source still cannot leave an idle process pooled. + const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal) + // Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a + // synchronous get-or-create so every spawned process remains owned by teardown. + this.assertActive(signal) + let instance = this.instanceFor(workspace) + if (instance.dead) { + this.evictIfCurrent(workspace, instance) + instance = this.instanceFor(workspace) + } + try { + return await instance.query(request, source, signal) + } finally { + // Drop a crashed slot only when it still owns this instance; a replacement must survive. + if (instance.dead) this.evictIfCurrent(workspace, instance) + } + }) + } + + /** Serialize one complete query lifecycle for a canonical workspace. */ + private enqueue(workspace: string, signal: AbortSignal | undefined, run: () => Promise): Promise { + const previous = this.queues.get(workspace) ?? Promise.resolve() + const result = abortable(previous, signal).then(run) + // The tail follows the actual prior work even when this caller aborts its wait. It never rejects, + // so later callers serialize without inheriting an earlier query's outcome. + const tail = previous.then(() => result).then(() => undefined, () => undefined) + this.queues.set(workspace, tail) + void tail.then(() => { + if (this.queues.get(workspace) === tail) this.queues.delete(workspace) + }) + return result } /** Return or synchronously publish the one instance for a canonical workspace. */ @@ -259,8 +284,13 @@ class LocalLspProvider implements LspProvider { async disposeAll(): Promise { this.disposed = true const live = [...this.instances.values()] + const draining = [...this.queues.values()] this.instances.clear() - await Promise.all(live.map(instance => instance.dispose())) + await Promise.all([ + ...live.map(instance => instance.dispose()), + ...draining, + ]) + this.queues.clear() } } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index fb67f6e777..9d327fb795 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -14,7 +14,8 @@ import type { LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' -import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { deadline } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' import type { ConnectionSpec } from './connection.ts' import type { HostSource } from './host.ts' @@ -83,7 +84,7 @@ export class LspInstance { // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up // rather than block on the shared tail forever. - const run = this.abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up // on the wait does not deserialize the queue. @@ -103,11 +104,11 @@ export class LspInstance { // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) this.capabilities = capabilities - this.connection.notify('initialized', {}) + await this.connection.notify('initialized', {}) } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { - if (this.disposed) throw new Error('LSP instance was disposed') + if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED') /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends @@ -115,11 +116,10 @@ export class LspInstance { // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. try { - await this.abortable(this.ready, signal) + await abortable(this.ready, signal) } catch (error) { if (!this.dead) { - /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ - await this.startTeardown(error instanceof Error ? error : new Error(String(error))) + await this.startTeardown() } throw error } @@ -138,7 +138,7 @@ export class LspInstance { try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) - this.connection.notify('textDocument/didOpen', { + await this.connection.notify('textDocument/didOpen', { textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, }) opened = true @@ -150,34 +150,21 @@ export class LspInstance { // the next queued query's document lifecycle overlap the still-active request. if (opened && !this.dead) { try { - this.connection.notify('textDocument/didClose', { textDocument: { uri } }) - } catch (error) { - /* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error' - listener, so a synchronous didClose write failure is a defensive path. */ + await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) + } catch { // A close-write failure does not replace the settled result/error, but the instance can no // longer be trusted: invalidate it and await bounded process termination. - void this.startTeardown(error instanceof Error ? error : new Error(String(error))) - /* v8 ignore stop */ + try { + await this.startTeardown() + } catch { + /* v8 ignore next -- teardown owns all expected process races; this only preserves the + already-settled query outcome if an unexpected cleanup primitive itself rejects. */ + } } } } } - /** - * Await `work`, but reject as soon as `signal` aborts. The underlying `work` promise keeps its own - * handlers, so an orphaned rejection after abort is not unhandled. - */ - private abortable(work: Promise, signal: AbortSignal | undefined): Promise { - if (signal === undefined) return work - /* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */ - if (signal.aborted) return Promise.reject(abortError(signal)) - return new Promise((resolve, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - signal.addEventListener('abort', onAbort, { once: true }) - work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) }) - }) - } - private async sendRequest( operation: LspOperation, uri: string, @@ -187,9 +174,9 @@ export class LspInstance { const params = { textDocument: { uri }, position: { line: position.line, character: position.character }, - // references always includes declarations: the caller gets no flag and impact analysis never - // omits the defining site. - ...(operation === 'references' ? { context: { includeDeclaration: true } } : {}), + // findReferences always includes declarations: the caller gets no flag and impact analysis + // never omits the defining site. + ...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}), } const requestId = this.connection.peekNextId() const send = this.connection.request(requestMethod(operation), params) @@ -204,7 +191,7 @@ export class LspInstance { */ private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { try { - return await this.abortable(send, signal) + return await abortable(send, signal) } catch (error) { if (!signal.aborted) throw error this.connection.cancel(requestId) @@ -221,7 +208,7 @@ export class LspInstance { grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) }), ]) - if (!settled) await this.startTeardown(abortError(signal)) + if (!settled) await this.startTeardown() } finally { grace[Symbol.dispose]() } @@ -263,17 +250,17 @@ export class LspInstance { * process close so nothing outlives disposal. */ async dispose(): Promise { - await this.startTeardown(new Error('LSP instance disposed')) + await this.startTeardown() } /** Publish disposal once and make every caller await the same quiescence boundary. */ - private startTeardown(reason: Error): Promise { + private startTeardown(): Promise { this.disposed = true - this.teardownPromise ??= this.tearDown(reason) + this.teardownPromise ??= this.tearDown() return this.teardownPromise } - private async tearDown(_reason: Error): Promise { + private async tearDown(): Promise { const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { await this.gracefulShutdown(shutdownDeadline.signal) @@ -287,9 +274,9 @@ export class LspInstance { /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ private async gracefulShutdown(signal: AbortSignal): Promise { - await this.abortable(this.connection.request('shutdown', null), signal) - this.connection.notify('exit', null) - await this.abortable(this.connection.closed, signal) + await abortable(this.connection.request('shutdown', null), signal) + await this.connection.notify('exit', null) + await abortable(this.connection.closed, signal) } /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ @@ -322,19 +309,6 @@ function markSettled(): boolean { return true } -/** - * Build an abort Error carrying the signal's reason (preserving a timeout classification). - * @param signal - the aborted signal whose reason to surface. - * @returns the timeout reason if the signal carries one, else the signal's Error reason, else a generic aborted Error. - */ -export function abortError(signal: AbortSignal): Error { - const timeout = timeoutOf(signal) - if (timeout !== undefined) return timeout - const reason: unknown = signal.reason - if (reason instanceof Error) return reason - return new Error('LSP query aborted') -} - /** * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and * configuration, markdown/plaintext hover, and link support for definition/implementation. No diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts index a208c3bedf..a49246c8bb 100644 --- a/packages/lsp/lsp-local/src/translate.ts +++ b/packages/lsp/lsp-local/src/translate.ts @@ -11,6 +11,7 @@ import type { LspOperation, LspRange, } from '@deepseek-ai/dsh-lsp' +import { LspError } from '@deepseek-ai/dsh-lsp' import { assertNever } from '@deepseek-ai/dsh-llm' import type { WireHover, @@ -30,9 +31,9 @@ import type { */ export function requestMethod(operation: LspOperation): string { switch (operation) { - case 'definition': return 'textDocument/definition' - case 'references': return 'textDocument/references' - case 'implementation': return 'textDocument/implementation' + case 'goToDefinition': return 'textDocument/definition' + case 'findReferences': return 'textDocument/references' + case 'goToImplementation': return 'textDocument/implementation' case 'hover': return 'textDocument/hover' /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ default: return assertNever(operation, 'requestMethod') @@ -42,9 +43,9 @@ export function requestMethod(operation: LspOperation): string { /** The `ServerCapabilities` provider field backing each operation. */ function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability { switch (operation) { - case 'definition': return capabilities.definitionProvider - case 'references': return capabilities.referencesProvider - case 'implementation': return capabilities.implementationProvider + case 'goToDefinition': return capabilities.definitionProvider + case 'findReferences': return capabilities.referencesProvider + case 'goToImplementation': return capabilities.implementationProvider case 'hover': return capabilities.hoverProvider /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ default: return assertNever(operation, 'capabilityValue') @@ -127,7 +128,12 @@ function isRange(value: unknown): value is WireRange { function isPosition(value: unknown): boolean { if (value === null || typeof value !== 'object') return false const position = value as Record - return typeof position.line === 'number' && typeof position.character === 'number' + return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character) +} + +/** Whether a wire coordinate is a valid nonnegative integer. */ +function isProtocolCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 } /** @@ -138,12 +144,13 @@ function isPosition(value: unknown): boolean { * @throws Error when an element is neither a `Location` nor a `LocationLink`. */ export function normalizeLocations(payload: unknown): LspLocation[] { - if (payload === null || payload === undefined) return [] + if (payload === null) return [] + if (payload === undefined) throw malformedResponse('LSP navigation result was missing') const elements = Array.isArray(payload) ? payload : [payload] const locations: LspLocation[] = [] for (const element of elements) { if (element === null || typeof element !== 'object') { - throw new Error('LSP navigation result contained a non-object entry') + throw malformedResponse('LSP navigation result contained a non-object entry') } const record = element as Record if (isLocationLink(record)) { @@ -153,7 +160,7 @@ export function normalizeLocations(payload: unknown): LspLocation[] { const location = record as unknown as WireLocation locations.push({ uri: location.uri, range: toRange(location.range) }) } else { - throw new Error('LSP navigation result contained neither a Location nor a LocationLink') + throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink') } } return locations @@ -168,39 +175,61 @@ function renderMarkedString(value: WireMarkedString): string { /** * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array - * joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps. + * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap. * @param payload - the raw `textDocument/hover` result. * @returns the normalized hover, or `null` when there is no content. * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. */ export function normalizeHover(payload: unknown): LspHover | null { - if (payload === null || payload === undefined) return null - if (typeof payload !== 'object') throw new Error('LSP hover result was not an object') + if (payload === null) return null + if (payload === undefined) throw malformedResponse('LSP hover result was missing') + if (typeof payload !== 'object') throw malformedResponse('LSP hover result was not an object') const hover = payload as unknown as WireHover const contents = renderHoverContents(hover.contents) if (contents === '') return null const range = hover.range - return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents } + if (range === undefined) return { contents } + if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range') + return { contents, range: toRange(range) } } /** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */ function renderHoverContents(contents: unknown): string { if (contents === null || contents === undefined) { - throw new Error('LSP hover result had no contents') + throw malformedResponse('LSP hover result had no contents') } if (typeof contents === 'string') return contents if (Array.isArray(contents)) { - return contents.map(renderMarkedString).join('\n\n') + return contents.map((value) => { + if (isMarkedString(value)) return renderMarkedString(value) + throw malformedResponse('LSP hover contents contained a malformed MarkedString') + }).join('\n\n') } if (typeof contents !== 'object') { - throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') } const record = contents as Record if (record.kind === 'markdown' || record.kind === 'plaintext') { - return typeof record.value === 'string' ? record.value : '' + if (typeof record.value !== 'string') { + throw malformedResponse('LSP hover MarkupContent value was not a string') + } + return record.value } if (typeof record.language === 'string' && typeof record.value === 'string') { return renderMarkedString({ language: record.language, value: record.value }) } - throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') +} + +/** Whether an untrusted value is either form of `MarkedString`. */ +function isMarkedString(value: unknown): value is WireMarkedString { + if (typeof value === 'string') return true + if (value === null || typeof value !== 'object') return false + const record = value as Record + return typeof record.language === 'string' && typeof record.value === 'string' +} + +/** Create the stable structured error used for malformed server result payloads. */ +function malformedResponse(message: string): LspError { + return new LspError(message, 'LSP_MALFORMED_RESPONSE') } diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 799069c0fd..a2da86d87c 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -18,9 +18,7 @@ const pkgDir = fileURLToPath(new URL('..', import.meta.url)) const seamLib = join(pkgDir, '../lsp/lib/index.js') const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) let root: string let ws: string @@ -49,13 +47,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { servers: { fake: { command: ${JSON.stringify(process.execPath)}, - args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], - env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + args: [${JSON.stringify(fixtureServer)}], + env: { LSP_FAKE_DEF: ${JSON.stringify(location)} }, extensionToLanguage: { '.ts': 'typescript' }, }, }, }) - const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) + const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 25ac9f2971..6d9ca6d6a3 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -2,9 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) /** A recorded server→client request the test's handler saw. */ interface SeenRequest { method: string; params: unknown } @@ -27,9 +25,9 @@ function connect( ): LspConnection { const conn = new LspConnection({ command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], + args: [fixtureServer], cwd: process.cwd(), - env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + env: { ...process.env as Record, ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, configuration: { setting: 42 }, @@ -69,7 +67,7 @@ describe('LspConnection', () => { seen, ) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) await waitFor(() => seen.some(s => s.method === 'workspace/configuration')) expect(seen[0]?.method).toBe('workspace/configuration') }) @@ -77,7 +75,7 @@ describe('LspConnection', () => { it('drops a server→client notification without replying', async () => { const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' }) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) // No throw and the connection stays usable. await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() }) @@ -90,7 +88,7 @@ describe('LspConnection', () => { seen, ) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit')) // The connection remains healthy after emitting the error response. await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() @@ -211,6 +209,15 @@ describe('LspConnection edge behavior', () => { await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) }) + it('rejects a pending request when child stdin closes but the process stays alive', async () => { + const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)') + await new Promise(resolve => setTimeout(resolve, 100)) + const timeout = new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('request timed out')) }, 1000) + }) + await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) + }) + it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { // A frame with a string id and no method: not dispatchable; the client must ignore it and still // answer our real request. diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 1654cb820d..8dec856274 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -12,6 +12,9 @@ * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request, * simulating a server that dies while idle so the pool holds a dead instance (eviction test). + * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. + * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. + * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of @@ -19,10 +22,10 @@ * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response. * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply. * - * Run: node --import tsx fixture-server.ts + * Run: node fixture-server.ts (Node's erasable TypeScript syntax support). */ -import { appendFileSync } from 'node:fs' +import { appendFileSync, closeSync } from 'node:fs' const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 @@ -30,6 +33,9 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse( const hang = process.env.LSP_FAKE_HANG === '1' const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' +const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) +const openMarker = process.env.LSP_FAKE_OPEN_MARKER +const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' @@ -124,17 +130,29 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul } if (method === 'textDocument/didOpen') { if (crashOnOpen) process.exit(1) + if (openMarker !== undefined) { + const params = message.params as { textDocument?: { text?: unknown } } | undefined + appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`) + } if (onOpen !== undefined) emitServerRequest(onOpen) return } if (method === 'textDocument/didClose' || method === 'initialized') return if (method?.startsWith('textDocument/')) { if (hang) return - if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } - send({ id, result: resultFor(method) }) - // Simulate an idle death: answer this request, then exit before the next one arrives so the pool - // is left holding a dead instance. - if (exitAfterReply) setTimeout(() => process.exit(0), 20) + const reply = (): void => { + if (closeStdinAfterReply) closeSync(0) + if (errorReply) { + send({ id, error: { code: -32000, message: 'server refused the request' } }) + } else { + send({ id, result: resultFor(method) }) + } + // Simulate an idle death: answer this request, then exit before the next one arrives so the + // pool is left holding a dead instance. + if (exitAfterReply) setTimeout(() => process.exit(0), 20) + } + if (replyDelayMs > 0) setTimeout(reply, replyDelayMs) + else reply() return } // Unknown request with an id: answer null so the client never stalls. @@ -172,3 +190,4 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() +if (closeStdinAfterReply) setInterval(() => {}, 1000) diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index 3fb9f804ac..8629502fd1 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -3,8 +3,13 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { realpath } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' +const execFileAsync = promisify(execFile) + let root: string let ws: string @@ -87,6 +92,19 @@ describe('readHostSource', () => { await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/) }) + it('rejects a FIFO with no writer without blocking in open', async () => { + const fifo = join(ws, 'pipe.ts') + await execFileAsync('mkfifo', [fifo]) + using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT') + await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/) + }) + + it('honors a pre-aborted source read before filesystem work', async () => { + const controller = new AbortController() + controller.abort(new Error('source read cancelled')) + await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/) + }) + it('treats the workspace root itself as inside, then rejects it as non-regular', async () => { // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the // directory then fails the regular-file check. diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index f46d72571b..6019d870fa 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -7,9 +7,7 @@ import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) let root: string let ws: string @@ -31,9 +29,9 @@ afterEach(async () => { function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], + args: [fixtureServer], cwd: ws, - env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + env: { ...process.env as Record, ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, @@ -46,12 +44,12 @@ function makeInstance(env: Record = {}, overrides: Partial { +async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise { const source = await readHostSource('a.ts', ws, 4_000_000) return instance.query(query(operation), source, signal) } @@ -91,43 +89,43 @@ describe('LspInstance server-request handling', () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() }) // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer // keeps the query working. - await expect(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) }) it('accepts a lifecycle client/registerCapability request', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) }) describe('LspInstance query and abort', () => { it('sends includeDeclaration for references', async () => { const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) - await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' }) }) it('rejects a query aborted before it starts', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() controller.abort(new Error('pre-abort')) - await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/) }) it('cancels an in-flight request on abort and rejects', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() // Warm the instance first so the abort lands during the hanging request, not during startup. - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -138,7 +136,7 @@ describe('LspInstance query and abort', () => { // down (its process closed) rather than left with an active request. const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -159,7 +157,7 @@ describe('LspInstance query and abort', () => { + '}});' const instance = scriptInstance(script, { killGraceMs: 2_000 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -173,7 +171,7 @@ describe('LspInstance query and abort', () => { // observed during that wait instead of hanging the tool-timeout signal. const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 150)) controller.abort(new Error('handshake-abort')) await expect(pending).rejects.toThrow(/handshake-abort/) @@ -182,7 +180,7 @@ describe('LspInstance query and abort', () => { it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).rejects.toThrow(/does not support definition/) + await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -190,7 +188,20 @@ describe('LspInstance query and abort', () => { // without treating it as an abort. const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) const controller = new AbortController() - await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) + }) + + it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ + kind: 'locations', + locations: [], + resolvedWorkspaceRoot: ws, + }) + expect(instance.dead).toBe(true) }) }) @@ -202,28 +213,28 @@ describe('LspInstance disposal', () => { LSP_FAKE_EXIT_DELAY_MS: '75', LSP_FAKE_EXIT_MARKER: marker, }, { shutdownTimeoutMs: 500 }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') }) it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() await expect(instance.dispose()).resolves.toBeUndefined() }) it('rejects a query after disposal', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() - await expect(run(instance, 'definition')).rejects.toThrow(/disposed/) + await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' })) }) it('reports dead after the process closes', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() expect(instance.dead).toBe(true) }) @@ -232,7 +243,7 @@ describe('LspInstance disposal', () => { // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it. const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});' const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await expect(instance.dispose()).resolves.toBeUndefined() }) @@ -244,7 +255,7 @@ describe('LspInstance disposal', () => { + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));` + RESPONDING_SERVER const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') const helperPid = Number(await readFile(marker, 'utf8')) try { const first = instance.dispose() @@ -259,7 +270,7 @@ describe('LspInstance disposal', () => { it('carries a non-Error abort reason as a generic aborted error', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 200)) controller.abort('a string reason, not an Error') await expect(pending).rejects.toThrow(/aborted/) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 3d06b7576b..826905ed26 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,9 +10,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) let root: string let ws: string @@ -32,8 +30,8 @@ afterEach(async () => { function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { return { command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], - env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, + args: [fixtureServer], + env: { ...fakeEnv }, extensionToLanguage: { '.ts': 'typescript' }, ...overrides, } @@ -79,7 +77,7 @@ describe('lsp-local end to end over a fake server', () => { it('resolves definition to normalized locations', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) - const result = await ctx.lsp.query(query('definition')) + const result = await ctx.lsp.query(query('goToDefinition')) expect(result).toEqual({ kind: 'locations', locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], @@ -91,14 +89,14 @@ describe('lsp-local end to end over a fake server', () => { it('maps a LocationLink for implementation', async () => { const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } } const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) }) - const result = await ctx.lsp.query(query('implementation')) + const result = await ctx.lsp.query(query('goToImplementation')) expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] }) await ctx.fiber.dispose() }) it('returns references (server includes the declaration)', async () => { const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) }) - const result = await ctx.lsp.query(query('references')) + const result = await ctx.lsp.query(query('findReferences')) expect(result).toMatchObject({ kind: 'locations' }) if (result.kind !== 'locations') throw new Error('expected locations') expect(result.locations).toHaveLength(2) @@ -114,7 +112,7 @@ describe('lsp-local end to end over a fake server', () => { it('returns an empty locations result for a null definition', async () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -126,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => { it('rejects a non-utf-16 position encoding at initialize', async () => { const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) await ctx.fiber.dispose() }) @@ -134,21 +132,21 @@ describe('lsp-local end to end over a fake server', () => { // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it. const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one. - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) await ctx.fiber.dispose() }) it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) await ctx.fiber.dispose() }) it('accepts openClose options sync', async () => { const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -162,25 +160,44 @@ describe('lsp-local end to end over a fake server', () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/) await ctx.fiber.dispose() }) it('serializes queries through one instance and runs them in order', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const results = await Promise.all([ - ctx.lsp.query(query('definition')), - ctx.lsp.query(query('definition')), - ctx.lsp.query(query('definition')), + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), ]) for (const result of results) expect(result).toMatchObject({ kind: 'locations' }) await ctx.fiber.dispose() }) + it('reads a queued query source only when its lifecycle starts', async () => { + const marker = join(root, 'opened.jsonl') + const ctx = await mount({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_REPLY_DELAY_MS: '300', + LSP_FAKE_OPEN_MARKER: marker, + }) + const first = ctx.lsp.query(query('goToDefinition')) + await waitFor(async () => (await markerLines(marker)).length === 1) + const second = ctx.lsp.query(query('goToDefinition')) + await writeFile(join(ws, 'a.ts'), 'const changed = 2\n') + await Promise.all([first, second]) + expect(await markerLines(marker)).toEqual([ + 'const x = 1\nconst y = x\n', + 'const changed = 2\n', + ]) + await ctx.fiber.dispose() + }) + it('aborts an in-flight query when the signal fires', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() - const pending = ctx.lsp.query(query('definition'), controller.signal) + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) controller.abort(new Error('caller cancelled')) await expect(pending).rejects.toThrow(/cancelled/) await ctx.fiber.dispose() @@ -190,7 +207,7 @@ describe('lsp-local end to end over a fake server', () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() controller.abort(new Error('pre-aborted')) - await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/) + await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/) await ctx.fiber.dispose() }) @@ -201,22 +218,22 @@ describe('lsp-local end to end over a fake server', () => { command: process.execPath, args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/) await ctx.fiber.dispose() }) it('classifies a timeout deadline as the abort reason', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) using d = deadline(undefined, 50, 'TEST_TIMEOUT') - await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) + await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) await ctx.fiber.dispose() }) it('fails the active query when the server crashes on open, and replaces it next query', async () => { const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() await ctx.fiber.dispose() }) @@ -225,10 +242,10 @@ describe('lsp-local end to end over a fake server', () => { // instance in the pool. The next query must evict-and-replace it and still succeed, rather than // failing once on the closed connection first. const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) - expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) // Wait past the fixture's post-reply exit so the pooled instance is observably dead. await new Promise(resolve => setTimeout(resolve, 60)) - expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) await ctx.fiber.dispose() }) @@ -237,11 +254,11 @@ describe('lsp-local end to end over a fake server', () => { // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance. const ctx = await mount({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() - const pending = ctx.lsp.query(query('definition'), controller.signal) + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) controller.abort(new Error('mid-read cancel')) await expect(pending).rejects.toThrow(/mid-read cancel/) // A subsequent live query still works, proving no half-created instance poisoned the pool. - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -251,8 +268,8 @@ describe('lsp-local end to end over a fake server', () => { await writeFile(join(ws2, 'a.ts'), 'const z = 2\n') const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const [r1, r2] = await Promise.all([ - ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }), - ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }), + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }), + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }), ]) expect(r1).toMatchObject({ kind: 'locations' }) expect(r2).toMatchObject({ kind: 'locations' }) @@ -261,7 +278,7 @@ describe('lsp-local end to end over a fake server', () => { it('disposes cleanly, terminating a server that ignores shutdown', async () => { const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 }) - await ctx.lsp.query(query('definition')) + await ctx.lsp.query(query('goToDefinition')) await expect(ctx.fiber.dispose()).resolves.toBeUndefined() }) @@ -280,3 +297,23 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) }) + +/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */ +async function markerLines(path: string): Promise { + try { + const text = await readFile(path, 'utf8') + return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */ +async function waitFor(condition: () => Promise, timeoutMs = 3000): Promise { + const started = Date.now() + while (!await condition()) { + if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 65a22bfb5b..8746b7a903 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' let root: string let ws: string @@ -22,7 +23,7 @@ afterEach(async () => { }) function query(): LspQueryRequest { - return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } + return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } } /** Wrap one server entry in the plugin's named server table. */ @@ -91,6 +92,30 @@ describe('lsp-local provider resolution', () => { await ctx.fiber.dispose() }) + it('rejects a nonpositive byte cap at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-cap', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + maxDocumentBytes: 0, + }))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/) + await ctx.fiber.dispose() + }) + + it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-timer', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + [name]: MAX_TIMER_DELAY_MS + 1, + }))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`)) + await ctx.fiber.dispose() + }) + it('rejects an absolute command that is not executable at load', async () => { const notExe = join(root, 'not-exe.txt') await writeFile(notExe, 'plain text, not executable') diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts index 7727112089..a68afebdd5 100644 --- a/packages/lsp/lsp-local/tests/translate.spec.ts +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -13,9 +13,9 @@ const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } describe('requestMethod', () => { it('maps each operation to its textDocument request', () => { - expect(requestMethod('definition')).toBe('textDocument/definition') - expect(requestMethod('references')).toBe('textDocument/references') - expect(requestMethod('implementation')).toBe('textDocument/implementation') + expect(requestMethod('goToDefinition')).toBe('textDocument/definition') + expect(requestMethod('findReferences')).toBe('textDocument/references') + expect(requestMethod('goToImplementation')).toBe('textDocument/implementation') expect(requestMethod('hover')).toBe('textDocument/hover') }) }) @@ -27,9 +27,9 @@ describe('supportsOperation', () => { referencesProvider: { workDoneProgress: true }, implementationProvider: false, } - expect(supportsOperation(caps, 'definition')).toBe(true) - expect(supportsOperation(caps, 'references')).toBe(true) - expect(supportsOperation(caps, 'implementation')).toBe(false) + expect(supportsOperation(caps, 'goToDefinition')).toBe(true) + expect(supportsOperation(caps, 'findReferences')).toBe(true) + expect(supportsOperation(caps, 'goToImplementation')).toBe(false) expect(supportsOperation(caps, 'hover')).toBe(false) }) }) @@ -66,9 +66,9 @@ describe('negotiatePositionEncoding', () => { }) describe('normalizeLocations', () => { - it('returns empty for null and undefined', () => { + it('returns empty only for the protocol no-result value null', () => { expect(normalizeLocations(null)).toEqual([]) - expect(normalizeLocations(undefined)).toEqual([]) + expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) it('maps a single Location', () => { @@ -100,6 +100,13 @@ describe('normalizeLocations', () => { it('rejects a Location whose range positions are malformed', () => { expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/) }) + + it('rejects negative and fractional position coordinates', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) }) describe('normalizeHover', () => { @@ -107,6 +114,10 @@ describe('normalizeHover', () => { expect(normalizeHover(null)).toBeNull() }) + it('rejects a missing hover result', () => { + expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + it('reads MarkupContent value and keeps a range', () => { expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE })) .toEqual({ contents: '# H', range: RANGE }) @@ -130,8 +141,9 @@ describe('normalizeHover', () => { expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull() }) - it('treats a MarkupContent with a non-string value as empty (null)', () => { - expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull() + it('rejects a MarkupContent with a non-string value', () => { + expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) it('rejects a non-object payload', () => { @@ -143,11 +155,19 @@ describe('normalizeHover', () => { expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/) }) + it('rejects a malformed MarkedString array member', () => { + expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeHover({ contents: [null] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + it('rejects a hover with no contents field', () => { expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/) }) - it('ignores a malformed range and keeps the contents', () => { - expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' }) + it('rejects a malformed range instead of silently dropping it', () => { + expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) }) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 300ce7d318..8ba61c0717 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -81,7 +81,7 @@ function locations(result: LspQueryResult): readonly { uri: string }[] { describe('real typescript-language-server', () => { it('resolves the definition of a call site to its declaration', async () => { // `export const text = describe(c)` (line 15): `describe` begins at column 21. - const result = await ctx.lsp.query(at('definition', 15, 22)) + const result = await ctx.lsp.query(at('goToDefinition', 15, 22)) const locs = locations(result) expect(locs.length).toBeGreaterThanOrEqual(1) expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true) @@ -89,7 +89,7 @@ describe('real typescript-language-server', () => { it('finds references to a symbol including its declaration', async () => { // References to `describe` from its declaration (line 10, col 17). - const result = await ctx.lsp.query(at('references', 10, 17)) + const result = await ctx.lsp.query(at('findReferences', 10, 17)) const locs = locations(result) // At least the declaration plus the call site. expect(locs.length).toBeGreaterThanOrEqual(2) @@ -97,7 +97,7 @@ describe('real typescript-language-server', () => { it('resolves implementations of an interface', async () => { // Implementations of `Shape` (line 1, col 18) → Circle. - const result = await ctx.lsp.query(at('implementation', 1, 18)) + const result = await ctx.lsp.query(at('goToImplementation', 1, 18)) const locs = locations(result) expect(locs.length).toBeGreaterThanOrEqual(1) }, 60_000) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 2f7fc6ff9f..df9ced7dc3 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -10,7 +10,7 @@ This package is the interface third of the LSP capability: | `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | | `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | -The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. +The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. ## Service API (`ctx.lsp`) @@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner ## Vocabulary -`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`. ## Model Experience diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index e00005acde..d7b1a80d01 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -1,6 +1,7 @@ /** * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query, - * order-independent selection over normalized definition/references/implementation/hover queries. + * order-independent selection over normalized goToDefinition/findReferences/goToImplementation/ + * hover queries. * * A provider reserves a branded id and an exclusive set of file extensions atomically: * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an @@ -42,8 +43,9 @@ declare module 'cordis' { /** * Structured LSP failure. Extends {@link HarnessError} with a stable `code` - * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that - * callers route on instead of parsing `message`. + * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, + * `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of + * parsing `message`. */ export class LspError extends HarnessError {} diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts index d1ae71dccd..d0c84f606d 100644 --- a/packages/lsp/lsp/src/types.ts +++ b/packages/lsp/lsp/src/types.ts @@ -14,7 +14,7 @@ import type { LspProviderId } from './brand.ts' * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are * deliberately deferred (they need different schemas). */ -export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' /** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ export interface LspPosition { @@ -73,9 +73,9 @@ export interface LspHover { } /** - * The closed result union. Navigation operations (`definition`, `references`, `implementation`) - * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` - * to exhaustiveness so a new arm breaks compilation until handled. + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. * * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that @@ -88,8 +88,9 @@ export type LspQueryResult = /** * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link - * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` - * always includes declarations — the provider enforces this internally; callers get no flag. + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. */ export interface LspProvider { /** Stable provider identity, reserved atomically with the extension mappings. */ diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 8561891df1..77ea9687c7 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -39,7 +39,7 @@ async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> { const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } } -function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters[0] { +function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters[0] { return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' } } diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index a3ffb9d871..4a844d2537 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -6,7 +6,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In ## The tool -`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. +`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. @@ -15,7 +15,7 @@ The tool requires the workspace root from the session `header.cwd`, with no fall | Key | Default | Meaning | |---|---|---| | `maxLocations` | `100` | Largest number of rendered locations before an omission marker. | -| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. | +| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. | | `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. | ## Model Experience @@ -29,7 +29,7 @@ One system-prompt section (order 112) positions LSP as a precision aid with the ##### Verbatim guidance ```markdown -Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration. +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. ``` #### Token effect @@ -58,11 +58,11 @@ Prefix-stable while the visible tool definition and order are unchanged; registr #### What the model sees -File-grouped `path:line:character` location lines or normalized hover text, capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results. +File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines. #### Token effect -Capped per tool result by the two limits above. +Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count. #### KV Cache effect diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 4559bbd1b5..abedad1e53 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-lsp", - "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization", + "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", "version": "0.0.1", "private": true, "type": "module", @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-lsp": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-lsp-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 3a37b7b6ed..c298bdffb8 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -1,9 +1,10 @@ /** * Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations - * (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor - * coordinates to the seam's zero-based positions, requires the session workspace with no fallback, - * caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to - * enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider. + * (`goToDefinition`/`findReferences`/`goToImplementation`/`hover`); it converts one-based UTF-16 + * cursor coordinates to the seam's zero-based positions, requires the session workspace with no + * fallback, caps and renders results, and attaches a configurable timeout budget for + * `dsh-timeout-policy` to enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and + * imports no provider. * * Namespace plugin (named exports, no default export). * @module @deepseek-ai/dsh-tool-lsp @@ -12,13 +13,14 @@ import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertNever, type ContentBlock } from '@deepseek-ai/dsh-llm' import { LspError } from '@deepseek-ai/dsh-lsp' import type {} from '@deepseek-ai/dsh-lsp' import type {} from '@deepseek-ai/dsh-system-prompt' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -28,8 +30,8 @@ import { import { sessionCwd } from './session-cwd.ts' export { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -50,22 +52,22 @@ export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000 /** The stable system-prompt guidance positioning LSP as a precision aid. */ export const LSP_PROMPT_TEXT = - 'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.' + 'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.' /** Plugin configuration: result caps and the timeout budget. */ export interface Config { /** Largest number of rendered locations before an omission marker (default 100). */ maxLocations?: number - /** Largest hover length in characters after normalization (default 16000). */ - maxHoverChars?: number + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number /** Tool-call timeout budget in ms (default 60000). */ timeoutMs?: number } export const Config: z = z.object({ maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS), - maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS), - timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS), + maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS), + timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS), }) type ResolvedConfig = Required @@ -78,21 +80,21 @@ type ResolvedConfig = Required export function apply(ctx: Context, config: Config): void { const resolved = config as ResolvedConfig assertPositiveInteger('maxLocations', resolved.maxLocations) - assertPositiveInteger('maxHoverChars', resolved.maxHoverChars) - assertPositiveInteger('timeoutMs', resolved.timeoutMs) + assertPositiveInteger('maxResultChars', resolved.maxResultChars) + assertTimer('timeoutMs', resolved.timeoutMs) ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT }) ctx.tools.register(defineTool({ name: 'lsp', description: - 'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.', + 'Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.', parameters: { operation: { type: 'string', required: true, enum: [...LSP_OPERATIONS], - description: 'definition, references, implementation, or hover.', + description: 'goToDefinition, findReferences, goToImplementation, or hover.', }, file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' }, line: { type: 'number', required: true, description: 'One-based line of the cursor.' }, @@ -116,9 +118,12 @@ export function apply(ctx: Context, config: Config): void { // Relativize against the provider's canonical workspace root (which its file: URIs are // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every // in-workspace location as external and render it as an absolute path. - return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }] + return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }] case 'hover': - return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] + return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }] + /* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */ + default: + return assertNever(result, 'tool-lsp result') } }, presentCall: presentLspCall, @@ -131,3 +136,10 @@ function assertPositiveInteger(name: string, value: number): void { throw new Error(`tool-lsp: ${name} must be a positive integer`) } } + +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index 0051adc719..b6341ae407 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -1,8 +1,8 @@ /** * Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor - * conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and - * ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it - * depends only on the tool arguments. + * conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result + * capping, and ACP presentation. No I/O — a UI may call the presenter on live streaming and on + * replay, so it depends only on the tool arguments. * @module @deepseek-ai/dsh-tool-lsp/render */ @@ -12,13 +12,13 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp' /** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */ -export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover'] +export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover'] /** Default cap on rendered locations before an omission marker is appended. */ export const DEFAULT_MAX_LOCATIONS = 100 -/** Default cap on hover characters (applied after normalization) before truncation is marked. */ -export const DEFAULT_MAX_HOVER_CHARS = 16_000 +/** Default cap on the complete rendered tool result, including truncation metadata. */ +export const DEFAULT_MAX_RESULT_CHARS = 16_000 /** Validated `lsp` arguments after coordinate checks. */ export interface LspToolInput { @@ -75,18 +75,20 @@ function oneBased(value: number, name: string): number { * Render a locations result grouped by file, converting each zero-based location back to a one-based * `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path; * outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and - * appends an omission marker when it truncates. + * appends an omission marker when it truncates by count, then applies the complete result cap. * @param locations - the seam's locations (possibly empty). * @param workspaceRoot - the canonical workspace root for relativizing `file:` paths. * @param maxLocations - the cap before truncation. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. * @returns the rendered text; a distinct no-result line when there are none. */ export function formatLocations( locations: readonly LspLocation[], workspaceRoot: string, maxLocations: number, + maxResultChars: number, ): string { - if (locations.length === 0) return 'No results.' + if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations') const shown = locations.slice(0, maxLocations) const omitted = locations.length - shown.length const grouped = new Map() @@ -103,20 +105,26 @@ export function formatLocations( if (omitted > 0) { lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`) } - return lines.join('\n') + return boundResult(lines.join('\n'), maxResultChars, 'locations') } /** - * Render a hover result, applying `maxHoverChars` last and marking truncation. + * Render a hover result, applying `maxResultChars` last and keeping its marker within the cap. * @param hover - the normalized hover, or `null` for no hover. - * @param maxHoverChars - the cap applied after normalization. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. * @returns the rendered hover text; a distinct no-result line for `null`. */ -export function formatHover(hover: LspHover | null, maxHoverChars: number): string { - if (hover === null) return 'No hover information.' - const contents = hover.contents - if (contents.length <= maxHoverChars) return contents - return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).` +export function formatHover(hover: LspHover | null, maxResultChars: number): string { + const text = hover === null ? 'No hover information.' : hover.contents + return boundResult(text, maxResultChars, 'hover') +} + +/** Bound a complete rendered result, including the truncation notice itself. */ +function boundResult(text: string, maxChars: number, label: string): string { + if (text.length <= maxChars) return text + const notice = `\n… ${label} truncated (limit ${maxChars} characters).` + if (notice.length >= maxChars) return notice.slice(0, maxChars) + return `${text.slice(0, maxChars - notice.length)}${notice}` } /** diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 5c98d9fff0..8b4cb79de6 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -78,7 +78,7 @@ function call(ctx: Context, args: unknown) { describe('tool-lsp integration', () => { it('round-trips a definition query through the real provider and renders a location', async () => { const ctx = await mount(false) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 }) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) await ctx.fiber.dispose() @@ -86,7 +86,7 @@ describe('tool-lsp integration', () => { it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => { const ctx = await mount(true, 300) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 }) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) expect(result.isError).toBe(true) expect(result.error?.code).toBe('TOOL_TIMEOUT') await ctx.fiber.dispose() diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 88b02a1fd5..a277539879 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { pathToFileURL } from 'node:url' import { join } from 'node:path' import { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -79,52 +79,63 @@ describe('renderUri', () => { describe('formatLocations', () => { it('renders a no-result line for an empty list', () => { - expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.') + expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.') }) it('renders one-based path:line:character grouped by file', () => { const a = pathToFileURL(join(WS, 'a.ts')).href - const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS) + const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS) expect(text).toBe('a.ts:1:1\na.ts:5:3') }) it('caps at maxLocations and marks the omission', () => { const a = pathToFileURL(join(WS, 'a.ts')).href const many = Array.from({ length: 5 }, (_, i) => loc(a, i)) - const text = formatLocations(many, WS, 2) + const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS) expect(text).toContain('a.ts:1:1') expect(text).toContain('3 more locations omitted (limit 2).') }) it('uses the singular omission marker for exactly one extra', () => { const a = pathToFileURL(join(WS, 'a.ts')).href - const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1) + const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS) expect(text).toContain('1 more location omitted (limit 1).') }) + + it('caps the complete location text even when one URI is enormous', () => { + const maxResultChars = 80 + const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars) + expect(text).toHaveLength(maxResultChars) + expect(text).toContain('locations truncated') + }) }) describe('formatHover', () => { it('renders a no-result line for null', () => { - expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.') + expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.') }) it('returns short hover verbatim', () => { - expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```') + expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```') }) - it('caps hover at maxHoverChars and marks truncation', () => { - const text = formatHover({ contents: 'a'.repeat(50) }, 10) - expect(text.startsWith('aaaaaaaaaa\n')).toBe(true) - expect(text).toContain('hover truncated (limit 10 characters).') + it('caps the complete hover text including its truncation marker', () => { + const text = formatHover({ contents: 'a'.repeat(100) }, 60) + expect(text).toHaveLength(60) + expect(text).toContain('hover truncated (limit 60 characters).') + }) + + it('still honors a cap smaller than the truncation marker', () => { + expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10) }) }) describe('presentLspCall', () => { it('is a generic search card with an operation/cursor title and a line location', () => { - expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({ + expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({ card: 'generic', kind: 'search', - title: 'LSP references a.ts:3:7', + title: 'LSP findReferences a.ts:3:7', locations: [{ path: 'a.ts', line: 3 }], }) }) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 577fa07396..37c995a3a6 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -5,6 +5,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' /** A scripted provider recording queries; `respond` yields the result or throws. */ function stubProvider( @@ -76,7 +77,7 @@ describe('tool-lsp registration', () => { it('exposes exactly the four operations in the schema enum', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } } - expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover']) + expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover']) }) it('has no default export (namespace plugin shape)', () => { @@ -86,16 +87,28 @@ describe('tool-lsp registration', () => { it('rejects a non-positive config value at load', async () => { await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/) }) + + it('rejects a timeout above Node timer range at load', async () => { + await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(/timeoutMs/) + expect(() => { + ToolLsp.apply(new Context(), { + maxLocations: 100, + maxResultChars: 16_000, + timeoutMs: MAX_TIMER_DELAY_MS + 1, + }) + }).toThrow(/timeoutMs/) + }) }) describe('tool-lsp execution', () => { it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => { const provider = stubProvider(() => okLocations) const { ctx } = await mount(provider) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws') expect(result.isError).toBe(false) expect(provider.seen[0]).toMatchObject({ - operation: 'definition', + operation: 'goToDefinition', filePath: 'a.ts', position: { line: 2, character: 4 }, workspaceRoot: '/ws', @@ -104,7 +117,7 @@ describe('tool-lsp execution', () => { it('renders locations relative to the workspace', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) - const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws') expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) @@ -118,7 +131,7 @@ describe('tool-lsp execution', () => { resolvedWorkspaceRoot: '/real/ws', })) const { ctx } = await mount(provider) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' }) expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) @@ -131,14 +144,14 @@ describe('tool-lsp execution', () => { it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null) expect(result.isError).toBe(true) expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED') }) it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') expect(result.isError).toBe(true) expect(result.error?.code).toBe('LSP_UNAVAILABLE') }) @@ -161,7 +174,7 @@ describe('tool-lsp execution', () => { }, } const { ctx } = await mount(provider) - await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') // The timeout policy is not mounted here, so the signal is whatever the registry passes (may be // undefined); the point is the tool threads it through without throwing. expect(seen).toHaveLength(1) diff --git a/packages/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json index be656effd2..e53735f570 100644 --- a/packages/lsp/tool-lsp/tsconfig.json +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/timeout" + }, { "path": "../lsp" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa837631f..c74251e7ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1512,6 +1512,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../timeout/timeout-policy From bc60b9ffe808f422d7563d689a1a8c5cfede6cd6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:52:15 +0800 Subject: [PATCH 40/48] fix(lsp): cancel blocked document opens --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/src/instance.ts | 13 +++-- .../lsp/lsp-local/tests/fixture-server.ts | 18 ++++++- packages/lsp/lsp-local/tests/instance.spec.ts | 49 +++++++++++++++++++ 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 82433d5fff..70b98f0cf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 91b15e8f9ff044d2c438c87040f9fc19a8dabc6a -2026-07-15-lsp-capability-seam.zh.md: 39e63370241e8bbeb93ea7bb81fbd951fe807b19 +2026-07-15-lsp-capability-seam.md: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708 +2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 91b15e8f9f..7265b04ac9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -119,7 +119,7 @@ The `read` tool is unsuitable source because its output is windowed, numbered, t The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. 1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. -2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. +2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it. 3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. 4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. @@ -177,7 +177,7 @@ The local provider trusts its configured server and claims no sandbox confinemen - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. - Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. - Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. -- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. +- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection. - Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. - Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. - Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 39e6337024..10e8956005 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -119,7 +119,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 -2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。 +2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。 3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 @@ -177,7 +177,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 - 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 -- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 +- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 - 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 - 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 66568b17d1..85cc7945dd 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. -- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. +- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 9d327fb795..74381c1483 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -138,9 +138,16 @@ export class LspInstance { try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) - await this.connection.notify('textDocument/didOpen', { - textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, - }) + try { + await abortable(this.connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, + }), signal) + } catch (error) { + // A canceled backpressured write or failed stdin leaves the protocol stream unusable before + // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. + await this.startTeardown() + throw error + } opened = true const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 8dec856274..c418ada519 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -14,6 +14,9 @@ * simulating a server that dies while idle so the pool holds a dead instance (eviction test). * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. + * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received. + * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. + * - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification. * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). @@ -35,6 +38,9 @@ const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) const openMarker = process.env.LSP_FAKE_OPEN_MARKER +const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER +const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' +const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1' const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER @@ -137,7 +143,13 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (onOpen !== undefined) emitServerRequest(onOpen) return } - if (method === 'textDocument/didClose' || method === 'initialized') return + if (method === 'initialized') { + if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') + if (pauseStdinAfterInitialized) process.stdin.pause() + if (closeStdinAfterInitialized) closeSync(0) + return + } + if (method === 'textDocument/didClose') return if (method?.startsWith('textDocument/')) { if (hang) return const reply = (): void => { @@ -190,4 +202,6 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() -if (closeStdinAfterReply) setInterval(() => {}, 1000) +if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { + setInterval(() => {}, 1000) +} diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 6019d870fa..328c8b7313 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -178,6 +178,40 @@ describe('LspInstance query and abort', () => { await instance.dispose() }) + it('terminates when abort interrupts a backpressured didOpen write', async () => { + // The fixture consumes initialized, then stops reading. A document larger than the stdio pipe + // keeps didOpen's write callback pending until cancellation forces bounded process teardown. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const marker = join(root, 'initialized.log') + const instance = makeInstance({ + LSP_FAKE_INITIALIZED_MARKER: marker, + LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1', + }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await waitForFile(marker) + // Let the client enter the large didOpen write after the fixture has paused stdin. + await new Promise(resolve => setTimeout(resolve, 100)) + controller.abort(new Error('didOpen-abort')) + await expect(pending).rejects.toThrow(/didOpen-abort/) + expect(instance.dead).toBe(true) + }) + + it('terminates when stdin fails during the didOpen write', async () => { + // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; + // the instance must still become dead so its provider can replace it. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + await expect(run(instance, 'goToDefinition')).rejects.toThrow() + expect(instance.dead).toBe(true) + }) + it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) @@ -287,3 +321,18 @@ function processAlive(pid: number): boolean { throw error } } + +/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */ +async function waitForFile(path: string, timeoutMs = 3000): Promise { + const started = Date.now() + for (;;) { + try { + await readFile(path) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} From a0268d25099c50f44b988d9c093af25e0032ca1f Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 21 Jul 2026 13:55:22 +0800 Subject: [PATCH 41/48] fix(compact-basic): replay conversation prefix so summarization reuses KV cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic compaction fires mid-conversation, right after the loop warmed the provider's KV cache with the last routed request. The default summarizer then issued a separate request whose prefix shared nothing with that warm request — a bespoke summarizer system prompt followed by the older history flattened to one rendered transcript string — so a differing first token invalidated the entire cached prefix and every compaction re-processed the whole replayed history twice. Move the compaction directive from the FRONT (a fresh system prompt) to the END (a trailing user message), and replay the last routed request's own system prompt, tools, message prefix, and shadowed-region messages verbatim via session.requestHeader() + deriveEventMessage. The auxiliary call is now a genuine prefix-extension of the warm request, so the provider reuses the cached tokens up to the trailing instruction. SummarizationInput carries the replayed prefix instead of a flat string; the now-unused renderTranscript/renderContentBlocks path is removed with its spec. Cache reuse is best-effort (head compaction guarantees a hit; a mid-range compaction or a differently-routed summarizer forgoes it), correctness is not. --- ...ction-summary-prefix-cache-reuse.i18n.yaml | 6 + ...1-compaction-summary-prefix-cache-reuse.md | 45 ++++++ ...ompaction-summary-prefix-cache-reuse.zh.md | 45 ++++++ .../2026-06-18-compaction-capability-seam.md | 2 +- docs/cordis-catalog/services.md | 2 +- packages/compact/compact-basic/README.md | 35 ++--- packages/compact/compact-basic/src/index.ts | 15 +- packages/compact/compact-basic/src/region.ts | 38 ++++- .../compact/compact-basic/src/summarizer.ts | 54 +++++-- .../compact-basic/tests/compact-basic.spec.ts | 88 +++++++++-- .../tests/compact-loop-repro.spec.ts | 7 +- packages/compact/compact/README.md | 18 +-- packages/compact/compact/src/index.ts | 1 - packages/compact/compact/src/render.ts | 95 ------------ packages/compact/compact/tests/render.spec.ts | 138 ------------------ 15 files changed, 271 insertions(+), 318 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md delete mode 100644 packages/compact/compact/src/render.ts delete mode 100644 packages/compact/compact/tests/render.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml new file mode 100644 index 0000000000..e6b0fa166a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml @@ -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 +2026-07-21-compaction-summary-prefix-cache-reuse.md: 490eb57a5891bf9cd0799c5d49d25d4e9838041f +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 02412ff07e87e12c7e7de00b5c69e1282433f735 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md new file mode 100644 index 0000000000..490eb57a58 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -0,0 +1,45 @@ +# Agent Note: The summarization call replays the conversation prefix for KV-cache reuse + +Status: implemented + +English | [中文](2026-07-21-compaction-summary-prefix-cache-reuse.zh.md) + +## Problem + +Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + `messagePrefix` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. + +## Decision + +The summarization directive moves from the **front** of the request (a fresh `system` prompt) to the **end** of the conversation (the final `user` message). The auxiliary call now reproduces the last routed request's prefix verbatim and appends one trailing instruction, so it is a genuine prefix-extension of the warm request and the provider reuses the cached tokens. + +### `SummarizationInput` carries the replayed prefix, not a rendered string + +`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system`, `tools`, and `messagePrefix`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. + +### The instruction is a trailing user message + +`COMPACTION_INSTRUCTION` opens "You are now acting as a compaction engine…" and directs the model to condense *the conversation ABOVE*. It keeps the prior checkpoint's structured headings and adds two rules the front-loaded system prompt did not need in its new position: do not mention the summarization request, and output only the checkpoint text without calling a tool. The shadowed region always ends on a tool-pairing-balanced boundary, so appending a `user` message after it is a valid message ordering for OpenAI-compatible and DeepSeek adapters. + +### Cache reuse is best-effort, correctness is not + +Auto-compaction always anchors at the surface head, so the shadowed region is the head of the routed request and the replayed prefix matches it exactly — the guaranteed-hit case. Manual mid-range `compactRegion` still replays the true prefix and stays correct, but forgoes reuse because its shadowed region is not the request head. A configured `summarizationProvider`/`summarizationModel` that differs from the conversation's route also forgoes reuse; that is the deployment's explicit trade-off, not a defect. Target resolution (configured override → latest routed header → agent options, else throw) is unchanged. + +## Alternatives considered + +- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache. +- **Send only the shadowed region without the `system`/`tools`/`messagePrefix` head** — rejected: a shorter or differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. +- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. +- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). + +## Consequences + +- **`dsh-compact-basic`** owns `SummarizationInput`; the protected `summarize(input, agent, signal?)` hook signature changed (acceptable pre-release), and `region.ts` gained `buildSummarizationInput` folding `deriveEventMessage` over the shadowed seqs behind the header prefix. +- **Dead render surface removed.** The old flattening path (`renderTranscript` / `renderContentBlocks` and its spec in `dsh-compact`) had no remaining consumer and was deleted with its export. +- **README model experience** for `dsh-compact-basic` now documents the auxiliary request as the replayed prefix plus a trailing compaction-instruction message, and its KV-cache effect as reuse of the warm conversation prefix. +- **The framed checkpoint output is unchanged**, so the landed `user/message` and every conversation-request snapshot are unaffected; only the auxiliary request's shape changed. + +## Testing + +- **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string. +- **Loop:** `compact-loop-repro.spec.ts` classifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop. +- **Snapshot gap unchanged:** the summarization call still emits no `assistant/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md new file mode 100644 index 0000000000..02412ff07e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 摘要调用回放对话前缀以复用 KV 缓存 + +Status: implemented + +[English](2026-07-21-compaction-summary-prefix-cache-reuse.md) | 中文 + +## Problem + +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + `messagePrefix` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 + +## Decision + +摘要指令从请求的**前端**(一个全新的 `system` 提示词)移到对话的**末尾**(最后一条 `user` 消息)。辅助调用现在逐字复现最后一个已路由请求的前缀,并追加一条尾部指令,因此它是已预热请求的真正前缀扩展,提供方会复用已缓存的 token。 + +### `SummarizationInput` 携带回放的前缀,而非渲染后的字符串 + +`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system`、`tools` 和 `messagePrefix`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 + +### 指令是一条尾部 user 消息 + +`COMPACTION_INSTRUCTION` 以 "You are now acting as a compaction engine…" 开头,指示模型浓缩*上方的对话*。它保留先前检查点的结构化标题,并在其新位置上新增了两条前置系统提示词此前不需要的规则:不要提及摘要请求,以及只输出检查点文本而不调用任何工具。被遮蔽区域总是结束在工具配对平衡的边界上,因此在其后追加一条 `user` 消息,对 OpenAI 兼容适配器和 DeepSeek 适配器而言是合法的消息排序。 + +### 缓存复用是尽力而为,正确性不是 + +自动压缩总是锚定在表层头部,因此被遮蔽区域就是已路由请求的头部,回放的前缀与之完全匹配,这就是保证命中的情形。手动的中段 `compactRegion` 仍然回放真实的前缀并保持正确,但会放弃复用,因为它的被遮蔽区域不是请求头部。配置的 `summarizationProvider`/`summarizationModel` 若与对话的路由不同,也会放弃复用;这是部署方明确的权衡,而非缺陷。目标解析(配置的覆盖值 → 最新的已路由 header → agent(智能体)选项,否则抛出)保持不变。 + +## Alternatives considered + +- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 +- **只发送被遮蔽区域而不带 `system`/`tools`/`messagePrefix` 头部**——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 +- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 +- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 + +## Consequences + +- **`dsh-compact-basic`** 拥有 `SummarizationInput`;受保护的 `summarize(input, agent, signal?)` 钩子签名发生变化(发布前可接受),并且 `region.ts` 新增了 `buildSummarizationInput`,它在 header 前缀之后对被遮蔽的 seq 折叠 `deriveEventMessage`。 +- **移除无用的渲染表面。** 旧的拍平路径(`renderTranscript` / `renderContentBlocks` 及其在 `dsh-compact` 中的 spec)已无消费方,连同其导出一并删除。 +- **README 的 Model Experience** 现在把 `dsh-compact-basic` 的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV 缓存效果记述为复用已预热的对话前缀。 +- **带框架的检查点输出未改变**,因此落地的 `user/message` 和每个对话请求快照都不受影响;只有辅助请求的形状发生了变化。 + +## Testing + +- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 +- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 +- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 所有。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 99313866ed..ff1dab7781 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -31,7 +31,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). ### Automatic pressure runs after successful durable step work diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5fa14917e1..bbaa79cb71 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -422,7 +422,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:40`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:39`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index d789bf2328..baebda057e 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`). +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`). This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -12,7 +12,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. @@ -75,30 +75,16 @@ Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces t Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable. -### Auxiliary summarizer user message +### Auxiliary summarizer request #### What the model sees -The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. +The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. -#### Token effect - -This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. - -#### KV Cache effect - -Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token. - -### Auxiliary summarizer system prompt - -#### What the model sees - -The summarization model receives the checkpoint-writing instruction below. - -##### Auxiliary summarizer system prompt +##### Compaction instruction (final user message) ```markdown -You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context. +You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context. Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. @@ -129,17 +115,18 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t Rules: - Preserve exact file paths, commands, error strings, identifiers, and function signatures. - Capture user feedback and explicit instructions faithfully, especially corrections. -- Do NOT mention this summarization process or that the context was compacted. -- If the transcript already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. +- Do NOT mention this summarization request or that the context was compacted. +- Output only the checkpoint text: do not call any tool or take any other action. +- If the conversation already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. ``` #### Token effect -Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. +This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once. #### KV Cache effect -Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction. +The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached. Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse. ## Known Limitations and Deferred Work diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 5fc02964b8..2220e28590 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -17,6 +17,7 @@ import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' +import type { SummarizationInput } from './summarizer.ts' import type { BasicCompactConfig, ResolvedConfig, @@ -135,19 +136,21 @@ export class BasicCompactService extends CompactService { } /** - * Summarize a rendered region through a direct one-shot `ctx.llm.stream()` - * call. Override this sole hook for a template or remote summarizer. - * @param text - plain-text conversation region to condense. + * Summarize the replayed conversation region through a direct one-shot + * `ctx.llm.stream()` call whose prefix reuses the conversation's own system + * prompt, tools, and messages so the provider's KV cache is not invalidated. + * Override this sole hook for a template or remote summarizer. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text summary blocks and exact auxiliary-call provenance. */ protected async summarize( - text: string, + input: SummarizationInput, agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return summarizeWithLlm(this.ctx, this.config, text, agent, signal) + return summarizeWithLlm(this.ctx, this.config, input, agent, signal) } /** @@ -236,7 +239,7 @@ export class BasicCompactService extends CompactService { const session = agent.session return compactSurfaceRegion({ meter: this.ctx.tokenMeter, - summarize: (text, owner, abort) => this.summarize(text, owner, abort), + summarize: (input, owner, abort) => this.summarize(input, owner, abort), }, session, start, end, agent, signal) } } diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index ac1ee260b1..86ae804e82 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -5,20 +5,20 @@ */ import { - renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { Message } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' -import type { SummaryResult } from './summarizer.ts' +import type { SummarizationInput, SummaryResult } from './summarizer.ts' interface RegionDependencies { readonly meter: TokenMeterService - summarize(text: string, agent: Agent, signal?: AbortSignal): Promise + summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise } /** @@ -122,8 +122,8 @@ export async function compactSurfaceRegion( throw new Error('compaction: selected surface changed before summarization began') } const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) - const text = renderTranscript(session.events, shadowedSeqs) - const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal) + const summarizationInput = buildSummarizationInput(session, shadowedSeqs) + const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal) const currentMeasurement = dependencies.meter.measure(session) if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) { @@ -173,6 +173,34 @@ export async function compactSurfaceRegion( } } +/** + * Reconstruct the last routed request's cacheable prefix for the shadowed + * region: its system prompt and tool schemas, then the request-only message + * prefix followed by the region's own derived messages in surface order. The + * summarizer appends only the compaction instruction after this, so the call + * is a genuine prefix of the conversation and reuses the provider's KV cache. + * @param session - session supplying the request header and per-node projection. + * @param shadowedSeqs - the surface-node seqs, in order, being compacted. + * @returns the replayed conversation prefix to condense. + */ +function buildSummarizationInput( + session: Session, + shadowedSeqs: readonly number[], +): SummarizationInput { + const header = session.requestHeader() + const events = session.events + const regionMessages = shadowedSeqs + // shadowedSeqs are current surface seqs, so each is a valid log index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map(seq => session.deriveEventMessage(events[seq]!)) + .filter((message): message is Message => message !== null) + return { + ...header?.system === undefined ? {} : { system: header.system }, + ...header?.tools === undefined ? {} : { tools: header.tools }, + messages: [...header?.messagePrefix ?? [], ...regionMessages], + } +} + /** Inspect the current turn boundary and latest compaction bracket once. */ function inspectTurnTail( events: readonly SessionEvent[], diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 32e308bce4..d38945aee5 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ResolvedConfig } from './types.ts' @@ -14,9 +14,15 @@ import type { ResolvedConfig } from './types.ts' const SUMMARY_OPEN_TAG = '' const SUMMARY_CLOSE_TAG = '' -/** Fixed structure required from the auxiliary summarization call. */ -const SUMMARIZE_SYSTEM_PROMPT = [ - 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', +/** + * The summarization directive, delivered as the FINAL user message after the + * replayed conversation rather than as a distinct summarizer system prompt. + * Keeping the conversation's own system prompt, tools, and message prefix in + * front of it makes the auxiliary call a genuine prefix of the last routed + * request, so the provider's KV cache is reused instead of invalidated. + */ +const COMPACTION_INSTRUCTION = [ + 'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.', '', 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', '', @@ -47,14 +53,30 @@ const SUMMARIZE_SYSTEM_PROMPT = [ 'Rules:', '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', '- Capture user feedback and explicit instructions faithfully, especially corrections.', - '- Do NOT mention this summarization process or that the context was compacted.', - `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, + '- Do NOT mention this summarization request or that the context was compacted.', + '- Output only the checkpoint text: do not call any tool or take any other action.', + `- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, ].join('\n') /** Framing that makes the replacement user message established context. */ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' +/** + * The replayed conversation surface the summarizer condenses. Reproducing the + * last routed request's system prompt, tools, and leading messages verbatim + * lets the auxiliary call reuse the provider's warm prefix cache; the trailing + * compaction instruction is then the only novel input. + */ +export interface SummarizationInput { + /** The conversation's own system prompt, reused for prefix-cache alignment; absent for a system-less request. */ + readonly system?: string + /** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */ + readonly tools?: readonly ToolSchema[] + /** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */ + readonly messages: readonly Message[] +} + /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ export interface SummaryResult { summary: ContentBlock[] @@ -64,10 +86,12 @@ export interface SummaryResult { } /** - * Run the default direct `ctx.llm.stream()` summarization call. + * Run the default cache-reusing `ctx.llm.stream()` summarization call: replay + * the conversation prefix, then append the compaction instruction as the final + * user message so the provider's warm prefix cache is reused. * @param ctx - context providing the LLM service. * @param config - resolved backend configuration. - * @param text - rendered transcript region to summarize. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text-only summary blocks and exact call provenance. @@ -75,7 +99,7 @@ export interface SummaryResult { export async function summarizeWithLlm( ctx: Context, config: ResolvedConfig, - text: string, + input: SummarizationInput, agent: Agent, signal?: AbortSignal, ): Promise { @@ -97,14 +121,16 @@ export async function summarizeWithLlm( } const assembler = new BlockAssembler() + const messages: Message[] = [ + ...input.messages, + { role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }] }, + ] const options: GenerateOptions = { provider: target.provider, model: target.model, - messages: [{ - role: 'user', - content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], - }], - system: SUMMARIZE_SYSTEM_PROMPT, + messages, + ...input.system === undefined ? {} : { system: input.system }, + ...input.tools === undefined ? {} : { tools: [...input.tools] }, maxTokens: config.maxTokens, sessionId: agent.session.id, ...signal === undefined ? {} : { signal }, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index d0340e8b0d..246a488641 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -3,11 +3,12 @@ import { Context } from 'cordis' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmFailure, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' @@ -26,6 +27,21 @@ function agent(session: Session, model?: string): Agent { return { session, options: model === undefined ? {} : { provider: model, model } } as Agent } +/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */ +function summarizedText(input: SummarizationInput): string { + const collect = (blocks: readonly ContentBlock[]): string => + blocks.map(block => + block.type === 'text' ? block.text + : block.type === 'tool-result' ? collect(block.content) + : '').join('\n') + return input.messages.map(message => collect(message.content)).join('\n') +} + +/** A minimal replayed prefix carrying one user message of the given text. */ +function promptInput(text: string): SummarizationInput { + return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] } +} + /** Closed two-message turns followed by one open turn for durable compaction events. */ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) @@ -141,14 +157,14 @@ class TestCompactService extends BasicCompactService { summaryModel = 'summary-model' error: unknown mutateDuringSummary: (() => void) | undefined - calls: Array<{ text: string; signal: AbortSignal | undefined }> = [] + calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = [] override async summarize( - text: string, + input: SummarizationInput, _agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - this.calls.push({ text, signal }) + this.calls.push({ input, signal }) this.mutateDuringSummary?.() if (this.error !== undefined) throw this.error return { @@ -503,8 +519,8 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') - expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300)) + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300)) }) it('retains the original compact-basic behavior without the optional plugin', async () => { @@ -541,7 +557,7 @@ describe('compaction region transaction', () => { expect(result.shadowedSeqs).toEqual(before.slice(0, 4)) expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) - expect(compact.calls[0]?.text).toContain('fixture user 1') + expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') const summary = session.events.findLast(event => event.type === 'compact/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, @@ -559,6 +575,25 @@ describe('compaction region transaction', () => { expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) + it('replays the latest routed header prefix so the summarizer reuses the cache', async () => { + const compact = service() + const session = conversation(3) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }] + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix }, + reason: 'resume', + }) + const nodes = session.surface.nodes + await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL) + + const { input } = compact.calls[0]! + expect(input.system).toBe('CONVERSATION SYSTEM') + expect(input.tools).toEqual(tools) + expect(input.messages[0]).toEqual(messagePrefix[0]) + expect(summarizedText(input)).toContain('fixture user 1') + }) + it.each([ ['start missing', 9_001, undefined, /start seq 9001 not found/], ['end missing', undefined, 9_002, /end seq 9002 not found/], @@ -772,11 +807,11 @@ class ScriptedAdapter extends LlmAdapter { class ExposedCompactService extends BasicCompactService { runSummarize( - text: string, + input: SummarizationInput, owner: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return this.summarize(text, owner, signal) + return this.summarize(input, owner, signal) } } @@ -808,7 +843,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, }) const session = conversation(1) - const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL) + const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], @@ -823,7 +858,28 @@ describe('default one-shot summarizer', () => { signal: SIGNAL, sessionId: session.id, }) - expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent') + const instruction = adapter.lastOptions?.messages.at(-1)?.content[0] + expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent') + }) + + it('replays the conversation prefix and appends the instruction as the final message', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } + await compact.runSummarize({ + system: 'REPLAYED SYSTEM', + tools, + messages: [prefix], + }, agent(conversation(1), MODEL)) + + expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM') + expect(adapter.lastOptions?.tools).toEqual(tools) + const messages = adapter.lastOptions?.messages ?? [] + expect(messages[0]).toEqual(prefix) + const last = messages.at(-1)?.content[0] + const lastText = last?.type === 'text' ? last.text : '' + expect(lastText).toContain('Condense the conversation ABOVE') + expect(lastText).toContain('## Primary Request and Intent') }) it('resolves the latest routed provider/model before the AgentOptions pair', async () => { @@ -833,7 +889,7 @@ describe('default one-shot summarizer', () => { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) - const output = await compact.runSummarize('history', agent(session, 'fallback')) + const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') @@ -867,7 +923,7 @@ describe('default one-shot summarizer', () => { await ctx.plugin(LlmService) void new TokenMeterService(ctx) const compact = new ExposedCompactService(ctx, { auto: false }) - await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less'))))) + await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -882,7 +938,7 @@ describe('default one-shot summarizer', () => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { - await compact.runSummarize('history', agent(conversation(1), MODEL)) + await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } @@ -894,7 +950,7 @@ describe('default one-shot summarizer', () => { it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) - await expect(compact.runSummarize('history', agent(conversation(1), MODEL))) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) @@ -1023,7 +1079,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) it('retries from a durable prune when later overflow summarization throws', async () => { diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e899979f46..cb977a6220 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -70,7 +70,12 @@ class OverflowRecoveryAdapter extends LlmAdapter { } override async * stream(options: GenerateOptions): AsyncIterable { - if (options.system?.includes('You are a compaction engine')) { + // The cache-reusing summarizer replays the conversation prefix and marks + // its call only by the compaction instruction in the trailing user message. + const trailing = options.messages.at(-1)?.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') ?? '' + if (trailing.includes('acting as a compaction engine')) { this.summaryRequests.push(options) yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } } diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 6e33b6e570..ed785b41ff 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | +| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | @@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and #### What the model sees -A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. +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 @@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request. -### Transcript supplied to a compaction consumer - -#### What the model sees - -`renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. - -#### Token effect - -Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. - -#### KV Cache effect - -No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference. - ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f4f666bfef..2a9d7955af 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -12,7 +12,6 @@ import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' -export { renderContentBlocks, renderTranscript } from './render.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' /** Why automatic policy is asking a backend to consider compaction. */ diff --git a/packages/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts deleted file mode 100644 index 48006d13b7..0000000000 --- a/packages/compact/compact/src/render.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Pure shared transcript projection for summarization and recall, so both - * render the same log span byte-for-byte under replay. - * @module @deepseek-ai/dsh-compact/render - */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -/** - * Render text directly, reasoning as a tagged span, and every other block as a - * type-tagged placeholder. Tool results recurse into nested content; empty - * blocks contribute nothing and rendered blocks join with newlines. - * - * @param blocks - the content blocks to render. - * @returns the newline-joined plain-text rendering; empty string when nothing renders. - */ -export function renderContentBlocks(blocks: readonly ContentBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - switch (block.type) { - case 'text': - if (block.text) parts.push(block.text) - break - case 'reasoning': - if (block.text) parts.push(`[reasoning: ${block.text}]`) - break - case 'tool-call': - parts.push(`[tool-call: ${block.name}(${block.arguments})]`) - break - case 'tool-result': { - const inner = renderContentBlocks(block.content) - parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') - break - } - // ContentBlockMap is merge-extensible — render an unknown block as a - // bare type-tagged placeholder so a plugin-added block type is still - // signalled to the reader rather than dropped. - default: - parts.push(`[${(block as ContentBlock).type}]`) - } - } - return parts.join('\n') -} - -/** - * Render message-producing events as a role-labeled transcript. `seqs` are - * walked in caller-supplied surface order, which may differ from numeric log - * order after replacement; non-surface and unknown merged events are skipped. - * - * @param events - the session log the seqs index into (`session.events`). - * @param seqs - the surface-node seqs to render, in surface order. - * @returns the transcript, entries joined by blank lines; empty string when nothing renders. - */ -export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string { - const lines: string[] = [] - - for (const seq of seqs) { - const event = events[seq] - if (!event) continue - - switch (event.type) { - case 'user/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`User: ${text}`) - break - } - case 'assistant/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`Assistant: ${text}`) - break - } - case 'tool/result': { - const text = renderContentBlocks(event.data.content) - const label = event.data.isError ? 'Tool error' : 'Tool result' - if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`) - break - } - case 'context/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Context: ${text}]`) - break - } - case 'steering/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Steering: ${text}]`) - break - } - default: - break - } - } - - return lines.join('\n\n') -} diff --git a/packages/compact/compact/tests/render.spec.ts b/packages/compact/compact/tests/render.spec.ts deleted file mode 100644 index 3b4bb41ac2..0000000000 --- a/packages/compact/compact/tests/render.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' - -function session(): Session { - return new Session(SessionId('render-spec')) -} - -describe('renderContentBlocks', () => { - it('renders text blocks verbatim and skips empty ones', () => { - expect(renderContentBlocks([ - { type: 'text', text: 'hello' }, - { type: 'text', text: '' }, - { type: 'text', text: 'world' }, - ])).toBe('hello\nworld') - }) - - it('wraps reasoning, skipping empty reasoning', () => { - expect(renderContentBlocks([ - { type: 'reasoning', text: 'think' }, - { type: 'reasoning', text: '' }, - ])).toBe('[reasoning: think]') - }) - - it('renders tool-call as a name(args) placeholder', () => { - expect(renderContentBlocks([ - { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' }, - ])).toBe('[tool-call: read({"filePath":"a"})]') - }) - - it('renders tool-result with nested content, and bare when empty', () => { - expect(renderContentBlocks([ - { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] }, - { type: 'tool-result', toolCallId: CallId('c2'), content: [] }, - ])).toBe('[tool-result: ok]\n[tool-result]') - }) - - it('renders an unknown (merge-extended) block type as a bare type tag', () => { - const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock - expect(renderContentBlocks([unknown])).toBe('[image]') - }) - - it('returns the empty string for no blocks', () => { - expect(renderContentBlocks([])).toBe('') - }) -}) - -describe('renderTranscript', () => { - it('renders each surface event type with its label, in the seq order given', () => { - const s = session() - const user = s.append('user/message', { - content: [{ type: 'text', text: 'fix the bug' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: 'looking' }], - }, { surfaceOp: 'append' }) - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c1'), - content: [{ type: 'text', text: 'exit 0' }], - isError: false, - }, { surfaceOp: 'append' }) - const context = s.append('context/message', { - content: [{ type: 'text', text: 'file changed' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const steering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: 'stop that' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - - expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([ - 'User: fix the bug', - 'Assistant: looking', - 'Tool result (call c1): exit 0', - '[Context: file changed]', - '[Steering: stop that]', - ].join('\n\n')) - }) - - it('labels an error tool result "Tool error"', () => { - const s = session() - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c9'), - content: [{ type: 'text', text: 'boom' }], - isError: true, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom') - }) - - it('renders NON-log-order seqs in the order given (surface order after a replace)', () => { - const s = session() - const first = s.append('user/message', { - content: [{ type: 'text', text: 'first' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const second = s.append('user/message', { - content: [{ type: 'text', text: 'second' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first') - }) - - it('skips events that render to nothing, non-message events, and seqs with no event', () => { - const s = session() - const empty = s.append('user/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: '' }], - }, { surfaceOp: 'append' }) - const emptyResult = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c3'), - content: [{ type: 'text', text: '' }], - isError: false, - }, { surfaceOp: 'append' }) - const emptyContext = s.append('context/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const emptySteering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - // A log-only (non-surface) event type: contributes nothing to a transcript. - const lock = s.append('compact/start', { turn: 0 }) - expect(renderTranscript(s.events, [ - empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999, - ])).toBe('') - }) -}) From 49a9bca9f6d68e22c76e3bad58ef78625194a102 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 21 Jul 2026 14:49:57 +0800 Subject: [PATCH 42/48] fix: validate compaction config pairs --- docs/config-catalog.md | 2 +- packages/compact/compact-basic/src/config.ts | 26 +++++++--- .../compact-basic/tests/compact-basic.spec.ts | 51 ++++++++++++++++++- .../tests/compact-loop-repro.spec.ts | 1 - .../tests/loader-composition.spec.ts | 17 ++++++- packages/llm/token-meter/src/index.ts | 4 +- packages/llm/token-meter/src/types.ts | 2 +- .../llm/token-meter/tests/token-meter.spec.ts | 9 +++- 8 files changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 55550cbe4a..28d7932fb0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,7 +1059,7 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ -export type TokenMeterConfig = object +export type TokenMeterConfig = Record ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 90adb5b5f7..b954537af8 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -248,17 +248,29 @@ function validatePolicy( assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries) } - const summarizationProvider = config.summarizationProvider - const summarizationModel = config.summarizationModel - if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') { + validateSummarizationPair(config, name) +} + +/** Require one scope to omit, clear, or replace the summarization target as a pair. */ +function validateSummarizationPair( + config: CompactPolicyConfig | Record, + name: string, +): void { + const provider = config.summarizationProvider + const model = config.summarizationModel + if (provider !== undefined && typeof provider !== 'string') { throw new Error(`${name}.summarizationProvider must be a string`) } - if (summarizationModel !== undefined && typeof summarizationModel !== 'string') { + if (model !== undefined && typeof model !== 'string') { throw new Error(`${name}.summarizationModel must be a string`) } - if (((summarizationProvider ?? '').length === 0) - !== ((summarizationModel ?? '').length === 0)) { - throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`) + if (provider === undefined && model === undefined) return + if (provider === undefined || model === undefined + || (provider.length === 0) !== (model.length === 0)) { + throw new Error( + `${name}: summarizationProvider and summarizationModel must be set together ` + + 'as an empty or non-empty pair', + ) } } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index cd1ebd2bd1..e6cc897576 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -308,6 +308,41 @@ describe('compact configuration and defaults', () => { }) }) + it('inherits, clears, and replaces the summarization target as a pair', () => { + const config = resolveConfig({ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [ + { provider: 'inherit-provider', model: MODEL }, + { + provider: 'clear-provider', + model: MODEL, + summarizationProvider: '', + summarizationModel: '', + }, + { + provider: 'replace-provider', + model: MODEL, + summarizationProvider: 'replacement-provider', + summarizationModel: 'replacement-model', + }, + ], + }) + + expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL })) + .toMatchObject({ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + }) + expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL })) + .toMatchObject({ summarizationProvider: '', summarizationModel: '' }) + expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL })) + .toMatchObject({ + summarizationProvider: 'replacement-provider', + summarizationModel: 'replacement-model', + }) + }) + it('validates common values and pressure-policy invariants', () => { const bad = [ [{ maxTokens: 0 }, /maxTokens/], @@ -316,8 +351,10 @@ describe('compact configuration and defaults', () => { [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/], - [{ summarizationModel: MODEL }, /must both be empty or both be non-empty/], + [{ summarizationProvider: MODEL }, /must be set together/], + [{ summarizationModel: MODEL }, /must be set together/], + [{ summarizationProvider: '' }, /must be set together/], + [{ summarizationModel: '' }, /must be set together/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], @@ -333,6 +370,16 @@ describe('compact configuration and defaults', () => { [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], + [{ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }], + }, /modelPolicies\[0\].*must be set together/], + [{ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }], + }, /modelPolicies\[0\].*must be set together/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], [ { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 9528683d2b..3326bd130c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -133,7 +133,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr auto: true, thresholdRatio: 0.5, retainTokens: 50, - summarizationModel: '', maxTokens: 8192, compactionRetries: 1, }) diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index c80f12c4bb..74f8423e52 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -85,7 +85,7 @@ describe('real Loader composition', () => { context = new Context() await expect(context.plugin(TokenMeterService, { contextWindow: 4096, - })).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) + } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -110,4 +110,19 @@ describe('real Loader composition', () => { }], })).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/) }) + + it('rejects an incomplete model-policy summarization pair during plugin load', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ + provider: 'test-provider', + model: 'test-model', + summarizationModel: '', + }], + })).rejects.toThrow(/modelPolicies\[0\].*must be set together/) + }) }) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fdfb064db..e17b0c06b6 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -80,7 +80,9 @@ declare module 'cordis' { /** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { - static Config: z = z.object({}) + // Schemastery preserves untrusted loader keys on an empty object schema; + // the public type excludes settings while validateConfigKeys rejects them. + static Config: z = z.object({}) as unknown as z private readonly states = new WeakMap() diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 33aa3d4bb5..255425b639 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -7,7 +7,7 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' /** Token-meter plugin configuration; the fixed estimator has no settings. */ -export type TokenMeterConfig = object +export type TokenMeterConfig = Record /** The baseline from which a signed surface delta produces current pressure. */ export type TokenMeasurementBaseline = diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index dc7d0c6e2e..13e196477a 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' @@ -87,10 +87,15 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void { } describe('TokenMeterService configuration and registration', () => { + it('exposes an empty public configuration type', () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ contextWindow: number }>().not.toExtend() + }) + it.each(['models', 'contextWindow', 'contextWidow'])( 'rejects stale or unknown top-level config key %s', (key) => { - expect(() => meter({ [key]: {} })) + expect(() => meter({ [key]: {} } as unknown as TokenMeterConfig)) .toThrow(`TokenMeterConfig: unknown key "${key}"`) }, ) From 504fbf59f3202126330517eb8b20f7f5a17c6f0c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:46:43 +0800 Subject: [PATCH 43/48] fix(invariants): replay histories and pack companions --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 4 +- ...-package-invariant-runtime-contracts.zh.md | 4 +- docs/module-graph.md | 3 +- packages/context/time-context/package.json | 1 + .../context/time-context/src/invariant.ts | 29 +++-- .../time-context/tests/invariant.spec.ts | 43 ++++++- packages/context/time-context/tsconfig.json | 3 + packages/goal/goal-session/tsdown.config.ts | 25 ++++ packages/goal/goal/tsdown.config.ts | 25 ++++ .../sandbox/sandbox-policy/src/invariant.ts | 24 ++-- .../sandbox-policy/tests/invariant.spec.ts | 17 ++- .../sandbox/sandbox-policy/tsdown.config.ts | 25 ++++ packages/todo/tool-todo/src/invariant.ts | 18 ++- .../todo/tool-todo/tests/invariant.spec.ts | 19 ++- packages/ui/permission/src/invariant.ts | 22 ++-- .../ui/permission/tests/invariant.spec.ts | 15 ++- scripts/verify-built-package-invariants.mjs | 115 +++++++++++++----- 18 files changed, 322 insertions(+), 74 deletions(-) create mode 100644 packages/goal/goal-session/tsdown.config.ts create mode 100644 packages/goal/goal/tsdown.config.ts create mode 100644 packages/sandbox/sandbox-policy/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 430e4e9f13..61d41ea571 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 4063acedfae1586e6a85e3b14a45bafe894a7a1f -2026-07-19-package-invariant-runtime-contracts.zh.md: a74d47329116438c7adadb9c05bd0f3ca9549f56 +2026-07-19-package-invariant-runtime-contracts.md: 86064ed36866675424826f60db15831879e86ea1 +2026-07-19-package-invariant-runtime-contracts.zh.md: 6194e05d75dfce8cb5f7833b1d2e6f86abeb46f3 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 4063acedfa..86064ed368 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -53,13 +53,13 @@ The current 100-package workspace has 21 executable companions and 79 justified | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | | `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. | -Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. +Session-backed companions validate existing durable events when they load, using the prefix preceding each candidate where the relationship depends on event order. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests `verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index a74d473291..6194e05d75 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -53,13 +53,13 @@ Status: implemented | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | | `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | -基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 +基于 session 的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 `verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 diff --git a/docs/module-graph.md b/docs/module-graph.md index 977662455b..4ab59607fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -306,6 +306,7 @@ flowchart TD pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -660,7 +661,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index ffbc3a87ee..2b3ad9fca6 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -32,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index c3d291c7af..45fdb48cba 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -19,10 +19,10 @@ export const name = 'time-context-invariant' export const inject = ['invariants'] /** Derive the pre-step position at which a time-context reading may append. */ -function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } { +function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { const currentTurnEvents: SessionEvent[] = [] let openTurn: number | undefined - for (const event of session.events.slice().reverse()) { + for (const event of history.slice().reverse()) { if (event.type === 'turn/end') { fail('time-context reading must be appended inside an open turn') } @@ -47,7 +47,7 @@ function preparationPosition(session: Session, fail: InvariantFailure): { turn: /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( - session: Session, + history: readonly SessionEvent[], event: SessionEvent<'context/message'>, fail: InvariantFailure, ): void { @@ -62,7 +62,7 @@ function validateReading( if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { fail('time-context turn and step must be positive safe integers') } - const expected = preparationPosition(session, fail) + const expected = preparationPosition(history, fail) if (turn !== expected.turn || step !== expected.step) { fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) } @@ -80,17 +80,30 @@ function validateReading( } } -/** Install validation for plugin-attributed context readings. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate all package-owned readings already present in one session. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const [index, event] of session.events.entries()) { + if (event.type !== 'context/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== SOURCE_NAME) continue + validateReading(session.events.slice(0, index), event, fail) + } +} + +/** Install validation for loaded and newly appended context readings. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'context/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session, event, fail) + validateReading(session.events, event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the time-context invariant companion. diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 4bcd0f6774..5da942603f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -9,7 +9,8 @@ const SECOND = Date.parse('2026-07-14T00:00:00Z') async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(TimeInvariant) return ctx } @@ -54,6 +55,13 @@ function preparing(turn: number, step: number): Session { return session } +function appendReading(session: Session, text: string): void { + session.append('context/message', { + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: 'time-context' }, + }, { surfaceOp: 'append' }) +} + describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() @@ -69,6 +77,37 @@ describe('time-context invariants', () => { }).not.toThrow() }) + it('validates each existing reading against its preceding durable prefix', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('time-invariant-late-valid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'prepare' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendReading(session, reading()) + session.append('step/start', { turn: 1, step: 1 }) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined() + }) + + it('rejects an invalid existing reading on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('time-invariant-late-invalid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'prepare' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendReading(session, reading('1', '2', 'step context')) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/) + }) + it.each([ [reading('1', '3', 'step context'), /expected turn 2\/step 3/], [reading('2', '2', 'step context'), /expected turn 2\/step 3/], diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index 491552def0..3f3c553e98 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -32,6 +32,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/goal/goal-session/tsdown.config.ts b/packages/goal/goal-session/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/goal/goal-session/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/goal/goal/tsdown.config.ts b/packages/goal/goal/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/goal/goal/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index c7d5af742d..90b8bf65fd 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { SANDBOX_MODES } from './session-mode.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-policy' @@ -12,16 +12,26 @@ export const name = 'sandbox-policy-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install validation for the durable sandbox-mode vocabulary. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { + fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) + } +} + +/** Install validation for loaded and newly appended sandbox modes. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { - fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) - } + validateEvent(event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register this package's invariant companion. diff --git a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts index 545625cc66..d3255b305e 100644 --- a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import * as SandboxPolicyInvariant from '@deepseek-ai/dsh-sandbox-policy/invariant' async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(SandboxPolicyInvariant) return ctx } @@ -37,4 +38,16 @@ describe('sandbox-policy invariants', () => { expect(() => { ctx.emit('session/event', {} as Session, modeEvent('host-root')) }) .toThrow(new InvariantError('@deepseek-ai/dsh-sandbox-policy', 'sandbox/mode carries unknown mode "host-root"')) }) + + it('rejects an unknown mode already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('sandbox/mode', { mode: 'host-root' as never }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(SandboxPolicyInvariant).then(() => undefined)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-sandbox-policy', + }) + }) }) diff --git a/packages/sandbox/sandbox-policy/tsdown.config.ts b/packages/sandbox/sandbox-policy/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/sandbox/sandbox-policy/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0b3a3739e4..d353c80f77 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -33,14 +33,24 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`) } -/** Install validation for durable whole-list todo snapshots. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'todo/write') validateTodos(event.data.todos, fail) +} + +/** Install validation for loaded and newly appended whole-list todo snapshots. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'todo/write') validateTodos(event.data.todos, fail) + validateEvent(event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the todo invariant companion. diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 8593f60299..abfcd74b29 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(TodoInvariant) return ctx } @@ -50,4 +51,18 @@ describe('todo snapshot invariants', () => { }) }).not.toThrow() }) + + it('rejects an invalid existing snapshot on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('todo/write', { + todos: [ + { content: 'duplicate', status: 'pending' }, + { content: 'duplicate', status: 'completed' }, + ], + }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/repeats content "duplicate"/) + }) }) diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 0be5942d24..b1290b7307 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' @@ -11,16 +11,24 @@ export const name = 'permission-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install validation that durable preset events remain resolvable. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: (message: string) => never) => { +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { + fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) + } +} + +/** Install validation that loaded and newly appended preset events remain resolvable. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(ctx, event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { - fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) - } + validateEvent(ctx, event, fail) }, { global: true }) -}, { inject: ['permission'] }) +}, { inject: ['permission', 'sessions'] }) /** * Register the permission invariant companion. diff --git a/packages/ui/permission/tests/invariant.spec.ts b/packages/ui/permission/tests/invariant.spec.ts index 6488239d38..9b903dc6a5 100644 --- a/packages/ui/permission/tests/invariant.spec.ts +++ b/packages/ui/permission/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context, Service } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -14,8 +14,9 @@ class PermissionProbe extends Service { async function setup(): Promise { const ctx = new Context() + await ctx.plugin(SessionStore) await ctx.plugin(PermissionProbe) - await ctx.plugin(InvariantService) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(PermissionInvariant) return ctx } @@ -39,4 +40,14 @@ describe('permission invariants', () => { expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) }) .toThrow(/unknown preset "missing"/) }) + + it('rejects an unknown preset already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(PermissionProbe) + ctx.sessions.create().append('permission/preset', { preset: 'missing' }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/) + }) }) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index b696964f4f..473f9ad9d2 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -1,7 +1,17 @@ -/** Verify every compiled companion through its package self-reference under plain Node. */ +/** Verify every packed companion through its package self-reference under plain Node. */ import { spawnSync } from 'node:child_process' -import { globSync, readFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -9,43 +19,82 @@ const root = resolve(import.meta.dirname, '..') const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() +const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) -for (const manifestPath of manifests) { - const packageDir = dirname(resolve(root, manifestPath)) - const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) - const packageName = manifest.name - if (typeof packageName !== 'string' || packageName.length === 0) { - failures.push(`${manifestPath}: missing package name`) - continue - } - - const probe = ` - const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); - const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); - if ('default' in companion) throw new Error('companion has a default export'); - const loader = Object.create(Loader.prototype); - const unwrapped = loader.unwrapExports(companion); - if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); - if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); - if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { - throw new Error('companion does not inject invariants'); +try { + for (const [index, manifestPath] of manifests.entries()) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue } - if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); - ` - const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { - cwd: packageDir, - encoding: 'utf8', - }) - if (result.status === 0) continue - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + + const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: packageDir, + encoding: 'utf8', + }) + if (pack.status !== 0) { + const detail = pack.error?.message + ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) + failures.push(`${packageName}: ${detail}`) + continue + } + + let files + try { + const result = JSON.parse(pack.stdout) + files = result[0]?.files + if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') + } catch (error) { + failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) + continue + } + + const stagedPackageDir = resolve(stagingRoot, String(index)) + for (const file of files) { + if (typeof file.path !== 'string' + || (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue + const target = resolve(stagedPackageDir, file.path) + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(resolve(packageDir, file.path), target) + } + const packageNodeModules = resolve(packageDir, 'node_modules') + if (existsSync(packageNodeModules)) { + symlinkSync(packageNodeModules, resolve(stagedPackageDir, 'node_modules'), 'dir') + } + + const probe = ` + const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); + const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); + if ('default' in companion) throw new Error('companion has a default export'); + const loader = Object.create(Loader.prototype); + const unwrapped = loader.unwrapExports(companion); + if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); + if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); + if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { + throw new Error('companion does not inject invariants'); + } + if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); + ` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { + cwd: stagedPackageDir, + encoding: 'utf8', + }) + if (result.status === 0) continue + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) + } +} finally { + rmSync(stagingRoot, { recursive: true, force: true }) } if (failures.length > 0) { - console.error('verify-built-package-invariants: compiled companion failures:') + console.error('verify-built-package-invariants: packed companion failures:') for (const failure of failures) console.error(` ${failure}`) process.exit(1) } -console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) +console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`) From 4e021b4686bd144610ec4ab1e6f34033c4ba0426 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:56:15 +0800 Subject: [PATCH 44/48] fix(ci): invoke npm portably on Windows --- scripts/verify-built-package-invariants.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 473f9ad9d2..26da80599c 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -20,6 +20,12 @@ const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).hre const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) +const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] +// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS +// entrypoint beside node.exe, so the probe stays shell-free on every runner. +const npmInvocation = process.platform === 'win32' + ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] + : ['npm', packArgs] try { for (const [index, manifestPath] of manifests.entries()) { @@ -31,7 +37,7 @@ try { continue } - const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + const pack = spawnSync(npmInvocation[0], npmInvocation[1], { cwd: packageDir, encoding: 'utf8', }) @@ -62,7 +68,11 @@ try { } const packageNodeModules = resolve(packageDir, 'node_modules') if (existsSync(packageNodeModules)) { - symlinkSync(packageNodeModules, resolve(stagedPackageDir, 'node_modules'), 'dir') + symlinkSync( + packageNodeModules, + resolve(stagedPackageDir, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ) } const probe = ` From d4b126862ea8778a5a8a4383d3d350bf2326fb58 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:11:24 +0800 Subject: [PATCH 45/48] fix(ci): preserve pnpm links in packed probes --- scripts/verify-built-package-invariants.mjs | 86 ++++++++++----------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 26da80599c..4b298946d1 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -3,15 +3,12 @@ import { spawnSync } from 'node:child_process' import { copyFileSync, - existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, - symlinkSync, } from 'node:fs' -import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -19,7 +16,6 @@ const root = resolve(import.meta.dirname, '..') const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() -const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] // Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS // entrypoint beside node.exe, so the probe stays shell-free on every runner. @@ -27,38 +23,41 @@ const npmInvocation = process.platform === 'win32' ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] : ['npm', packArgs] -try { - for (const [index, manifestPath] of manifests.entries()) { - const packageDir = dirname(resolve(root, manifestPath)) - const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) - const packageName = manifest.name - if (typeof packageName !== 'string' || packageName.length === 0) { - failures.push(`${manifestPath}: missing package name`) - continue - } +for (const manifestPath of manifests) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue + } - const pack = spawnSync(npmInvocation[0], npmInvocation[1], { - cwd: packageDir, - encoding: 'utf8', - }) - if (pack.status !== 0) { - const detail = pack.error?.message - ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) - failures.push(`${packageName}: ${detail}`) - continue - } + const pack = spawnSync(npmInvocation[0], npmInvocation[1], { + cwd: packageDir, + encoding: 'utf8', + }) + if (pack.status !== 0) { + const detail = pack.error?.message + ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) + failures.push(`${packageName}: ${detail}`) + continue + } - let files - try { - const result = JSON.parse(pack.stdout) - files = result[0]?.files - if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') - } catch (error) { - failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) - continue - } + let files + try { + const result = JSON.parse(pack.stdout) + files = result[0]?.files + if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') + } catch (error) { + failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) + continue + } - const stagedPackageDir = resolve(stagingRoot, String(index)) + // Keep the packed view below its owning package so Node reaches the real + // pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's + // relative workspace links on Windows. + const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-')) + try { for (const file of files) { if (typeof file.path !== 'string' || (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue @@ -66,14 +65,6 @@ try { mkdirSync(dirname(target), { recursive: true }) copyFileSync(resolve(packageDir, file.path), target) } - const packageNodeModules = resolve(packageDir, 'node_modules') - if (existsSync(packageNodeModules)) { - symlinkSync( - packageNodeModules, - resolve(stagedPackageDir, 'node_modules'), - process.platform === 'win32' ? 'junction' : 'dir', - ) - } const probe = ` const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); @@ -92,13 +83,14 @@ try { cwd: stagedPackageDir, encoding: 'utf8', }) - if (result.status === 0) continue - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + if (result.status !== 0) { + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) + } + } finally { + rmSync(stagedPackageDir, { recursive: true, force: true }) } -} finally { - rmSync(stagingRoot, { recursive: true, force: true }) } if (failures.length > 0) { From 1cee25fdeab3c965b9ca98e760dbea8991da4e1a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:27 +0800 Subject: [PATCH 46/48] fix(tui): resolve context capacity for active model --- packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 37 ++++++++++++++++++++++++++++--- packages/ui/tui/tests/harness.ts | 8 +++++-- packages/ui/tui/tests/tui.spec.ts | 30 +++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 4cfa958e8e..18fda35648 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 34263ffadd..137ef7a264 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -737,7 +737,7 @@ class FooterComponent implements Component { private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number, + private readonly contextPercent: () => number | undefined, private readonly runningSeconds: () => number, ) {} @@ -755,7 +755,8 @@ class FooterComponent implements Component { const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` const model = displayText(this.currentModel() ?? 'model unset') const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` - const context = `${this.contextPercent()}% context` + const contextPercent = this.contextPercent() + const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context` const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` const compactRight = `${context} ${modelState}` if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { @@ -1058,6 +1059,11 @@ export function createTuiChat( let activeQuestion: PendingQuestion | undefined let modelOverlay: OverlayHandle | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } + let contextWindow: number | undefined + let contextResolution: Promise< + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() @@ -1070,7 +1076,9 @@ export function createTuiChat( () => showReasoning, () => tokens, () => target.current?.model, - () => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)), + () => contextWindow === undefined + ? undefined + : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), ) ui.addChild(header) @@ -1096,12 +1104,34 @@ export function createTuiChat( const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelContext(selected.provider, selected.model).then( + context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const), + (error: unknown) => ({ kind: 'error', error } as const), + ) + contextResolution = resolution + void resolution.then((result) => { + if (contextResolution !== resolution) return + if (result.kind === 'error') { + appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + requestRender() + }) + } + resolveContextWindow(target.current) + const selectModel = (selected: ModelChoice): void => { if (target.current?.provider === selected.provider && target.current.model === selected.model) { appendNotice(`Model is already ${targetLabel(selected)}.`) return } target.current = { provider: selected.provider, model: selected.model } + resolveContextWindow(target.current) appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) } @@ -1432,6 +1462,7 @@ export function createTuiChat( const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true + contextResolution = undefined clearStatus() modelOverlay?.hide() modelOverlay = undefined diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 7bdd8d479f..fe37afe72e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,7 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -31,6 +31,7 @@ export interface TuiHarnessOptions { providers: LlmProviderInfo[] models: LlmModelInfo[] listModels?: (provider: string) => Promise + resolveModelContext?: (provider: string, model: string) => Promise } } @@ -75,9 +76,12 @@ export async function createTuiTestHarness model.provider === provider).map(model => ({ ...model }))) }, + resolveModelContext(provider: string, model: string) { + return catalog.resolveModelContext?.(provider, model) + ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) + }, } as never) ctx.provide('tokenMeter', { - contextWindow: options.contextWindow ?? 128_000, measure() { return { totalTokens: options.contextTokens ?? 0 } }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e331c74b17..1a2e70049e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -115,7 +115,6 @@ async function dispose(setupResult: Awaited>): Promise< function provideTokenMeter(ctx: Context): void { ctx.provide('tokenMeter', { - contextWindow: 128_000, measure() { return { totalTokens: 0 } }, @@ -537,8 +536,10 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { + const initialContext = Promise.withResolvers<{ contextWindow: number }>() const result = await setup({ agentOptions: { provider: 'alpha', model: 'a1' }, + contextTokens: 50, catalog: { providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }], models: [ @@ -547,6 +548,9 @@ describe('pi-tui chat lifecycle and transcript', () => { { provider: 'beta', id: 'b1', name: 'Beta One' }, { provider: 'beta', id: 'shared', name: 'Beta Shared' }, ], + resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1' + ? initialContext.promise + : Promise.resolve({ contextWindow: 200 }), }, }) @@ -574,6 +578,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Model selected: beta/b1') expect(result.agent.sent).toEqual([]) expect(result.agent.steered).toEqual([]) + initialContext.resolve({ contextWindow: 100 }) + await tick() + expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)') result.terminal.send('/model') result.terminal.send('\r') @@ -584,7 +591,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() - expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)') + expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)') const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) @@ -620,6 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [{ id: 'alpha', name: 'Alpha' }], models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelContext: () => Promise.resolve(undefined), }, }) unset.terminal.send('/model') @@ -628,6 +636,7 @@ describe('pi-tui chat lifecycle and transcript', () => { unset.terminal.send('\r') await tick() expect(unset.terminal.output).toContain('Model selected: alpha/a1') + expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)') await dispose(unset) const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) @@ -649,12 +658,14 @@ describe('pi-tui chat lifecycle and transcript', () => { providers: [{ id: 'deepseek', name: 'DeepSeek' }], models: [], listModels: () => Promise.reject(new Error('catalog offline')), + resolveModelContext: () => Promise.reject(new Error('capacity offline')), }, }) failed.terminal.send('/model') failed.terminal.send('\r') await tick() expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline') + expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline') await dispose(failed) }) @@ -690,6 +701,21 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(rejectedResult.terminal.output).not.toContain('late catalog failure') await rejectedResult.ctx.fiber.dispose() + + const context = Promise.withResolvers<{ contextWindow: number }>() + const contextResult = await setup({ + contextTokens: 99, + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + resolveModelContext: () => context.promise, + }, + }) + await contextResult.controller.dispose() + context.resolve({ contextWindow: 100 }) + await tick() + expect(contextResult.terminal.output).not.toContain('99% context') + await contextResult.ctx.fiber.dispose() }) it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => { From 39c9bf3ef2d617b1fd520d63b96f8179c868d811 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:57:25 +0800 Subject: [PATCH 47/48] fix(llm-replay): publish recorded model capacity --- .../tui-agent/tests/fixtures/tui-scripted-llm.ts | 6 +++++- examples/tui-agent/tests/tui.snapshot.ts | 2 +- packages/support/llm-replay/README.md | 3 ++- packages/support/llm-replay/src/index.ts | 12 +++++++++++- packages/support/llm-replay/tests/llm-replay.spec.ts | 6 +++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 020394ebe3..2806e205a4 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,5 +1,5 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' @@ -25,6 +25,10 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } + override resolveModelContext(_provider: string, _model: string): Promise { + return Promise.resolve({ contextWindow: 128_000 }) + } + override async * stream(options: GenerateOptions): AsyncIterable { if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index cc8e9847cb..1ac3f5b55e 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -33,7 +33,7 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') // Keep pre-normalization layout widths identical across macOS and Linux. const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp' -const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash' }] }] +const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi type SnapshotMode = 'replay' | 'record' | 'refresh' diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 4c8966333b..0d89d4337d 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -23,7 +23,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | ```yaml - id: llm-replay @@ -34,6 +34,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s name: DeepSeek models: - id: deepseek-v4-flash + contextWindow: 128000 - id: deepseek-v4-pro # file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE / # $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 3299905a07..5a254cca2d 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' /** @@ -31,6 +31,8 @@ export interface ReplayModelConfig { name?: string /** Optional selector description. */ description?: string + /** Optional positive integer context capacity published by the replay adapter. */ + contextWindow?: number } /** One provider route exposed by the replay adapter. */ @@ -260,6 +262,14 @@ class ReplayAdapter extends LlmAdapter { }))) } + override resolveModelContext(provider: string, model: string): Promise { + const configured = this.providers.get(provider) + /* v8 ignore next -- LlmService only asks about routes registered from this same map. */ + if (configured === undefined) return Promise.resolve(undefined) + const contextWindow = configured.models?.find(candidate => candidate.id === model)?.contextWindow + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + override stream(options: GenerateOptions): AsyncIterable { return this.replay(options) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 846085deec..b9a05e47ea 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -228,7 +228,7 @@ describe('installLlmReplay (through the real LlmService)', () => { id: 'deepseek', name: 'DeepSeek', models: [ - { id: 'flash' }, + { id: 'flash', contextWindow: 128_000 }, { id: 'pro', name: 'Pro', description: 'Larger model' }, ], }, @@ -245,6 +245,10 @@ describe('installLlmReplay (through the real LlmService)', () => { { provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' }, ]) await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) + await expect(ctx.llm.resolveModelContext('deepseek', 'flash')).resolves.toEqual({ contextWindow: 128_000 }) + await expect(ctx.llm.resolveModelContext('deepseek', 'pro')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('empty', 'unlisted')).resolves.toBeUndefined() expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS) dispose() From 373bf333ac23aedbcd356545d3fc84639b3eca5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:05:40 +0800 Subject: [PATCH 48/48] docs: refresh config catalog for replay capacity --- docs/config-catalog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fe30bad4e..a4c27804c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -608,10 +608,12 @@ export interface ReplayModelConfig { name?: string /** Optional selector description. */ description?: string + /** Optional positive integer context capacity published by the replay adapter. */ + contextWindow?: number } ``` -Source: [`packages/support/llm-replay/src/index.ts:375`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:385`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry`