mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into fix/workspace-instruction-frame-metadata
This commit is contained in:
@@ -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-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6
|
||||
2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: Compiler-independent Typert type model
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-compiler-independent-typert-model.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Constructing Zod and reflection text directly from the TypeScript AST couples type analysis and business-semantic recognition to a single generation target. Such a generator can answer only “can this syntax be generated?” It cannot provide a canonical representation of packages, faces, public exports, services, events, objects, and their type relationships, nor can static checks and later generation targets reuse it.
|
||||
|
||||
The host and client are independent TypeScript projects; placing both in one `ts.Program` merges conflicting Cordis `Context` and `Events` declarations. At the same time, client types still need to reference host types explicitly, so neither complete isolation nor duplicating types on both sides can express the actual dependencies.
|
||||
|
||||
## Decision
|
||||
|
||||
[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) builds separate `ts.Program` instances from the host and client projects and uses compiler nodes, symbols, and checkers only as extraction tools. After analysis, every generator and scanner consumes only Typert's own `WorkspaceModel`, `FaceModel`, and `TypeGraph`; the model retains no AST or checker objects. The generator has no dependency on `@deepseek-ai/dsh-typert-registry`.
|
||||
|
||||
TypeGraph preserves the developer-authored, pre-evaluation type structure, including generic parameters and applications, explicit inheritance, conditional and mapped types, recursive references, and JSDoc. A reachable type that cannot be represented losslessly causes analysis to fail. If an emitter cannot handle an already modeled node, that emitter fails instead of flattening the type or degrading it to `unknown`.
|
||||
|
||||
Each face independently owns a PackageModel and TypeGraph. Direct project references from `tsconfig.host.json` and `tsconfig.client.json` determine a package's face membership, while `package.json#exports` defines its public boundary. Cross-face relationships come only from explicit imports or re-exports in source and remain separate links; external npm types are recorded as External without reading or copying their declarations.
|
||||
|
||||
PackageModel recognizes Cordis services, events, `@typert object` reference objects, and `@typert schema` data roots. Services and objects expose only public instance members, excluding constructors and static, private, and protected members; inheritance edges remain in TypeGraph instead of being copied into flattened members. When a public property, parameter, or return type lacks an annotation, `check` mode reports an error, while `write` mode writes the checker-inferred result, rebuilds the project, and analyzes it again in strict mode.
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas.
|
||||
|
||||
Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
|
||||
|
||||
At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog.
|
||||
|
||||
## Verification contract
|
||||
|
||||
A small two-face project in the repository snapshots the complete type model, including its source declaration index. Batched workspace analysis and direct focused analysis must produce model-equivalent `FaceModel` and `TypeGraph` results for the same faces. Compile-time exhaustive maps and runtime set comparisons ensure that every node, target, declaration, and member discriminant is exercised by source-authored TypeScript syntax; a field-semantics matrix covers every keyword, type operator, and literal value category, plus every state of generics, parameters, tuples, mapped modifiers, import attributes, abstract forms, predicates, and enum initializers.
|
||||
|
||||
For every property in `SyntaxZoo`, the TypeScript printer normalizes the source type, which must exactly match the TypeGraph rendering; TypeScript then recompiles every rendered declaration. This layer checks that each node's internal information is preserved losslessly, including no-substitution template literals, type queries with type arguments, and constrained `infer`, without substituting discriminant coverage or code coverage for structural equivalence.
|
||||
|
||||
Boundary cases pin explicit package imports within and across faces, cross-face named re-exports, exact export aliases, qualified `import()` links, and the External classification of global `@types` declarations; they reject TypeScript diagnostics originating in package-owned files, relative-path boundary crossings, references outside `package.json#exports`, and cross-face namespace re-exports without a model target. Interface declaration merging explicitly preserves every authored part; other merges that cannot be represented losslessly fail.
|
||||
|
||||
For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute the JavaScript, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Retain the TypeScript AST directly.** The AST preserves source syntax, but it would make every consumer depend on the compiler lifecycle, node identity, and checker context, preventing a stable architectural boundary. It is therefore used only during extraction.
|
||||
|
||||
**Generate final types from the checker.** A flattened `ts.Type` is easy to traverse directly, but it loses the developer's expression of generics, conditional and mapped types, and alias applications, so it cannot support reflection and later generation needs.
|
||||
|
||||
**Merge the host/client projects or duplicate host types.** Merging would contaminate Cordis declaration merging; duplication would create a second source of truth for types. Independent faces with explicit cross-face links preserve project isolation and actual reference relationships.
|
||||
|
||||
**Make `dsh-typert-registry` responsible for type resolution and cross-package composition.** That would recouple the TypeScript compiler, Cordis lifecycle, and a specific schema policy. The registry remains a lifecycle container for generated artifacts, while the build-time model retains complex analysis.
|
||||
|
||||
## Consequences
|
||||
|
||||
New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
|
||||
|
||||
Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: 编译器无关的 Typert 类型模型
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-compiler-independent-typert-model.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
直接从 TypeScript AST 拼接 Zod 和反射文本,会把类型分析、业务语义识别与单个生成目标绑在一起。这样的生成器只能回答“这段语法能否生成”,无法提供包、face、公开导出、service、event、对象及其类型关系的标准表示,也无法供静态检查和后续生成目标复用。
|
||||
|
||||
host 与 client 属于独立 TypeScript project;把两者放进同一个 `ts.Program` 会合并冲突的 Cordis `Context` 与 `Events` 声明。与此同时,client 类型仍需显式引用 host 类型,因此完全隔离或在两边复制类型都不能表达真实依赖。
|
||||
|
||||
## Decision
|
||||
|
||||
[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) 分别从 host 和 client project 建立 `ts.Program`,只把 compiler node、symbol 和 checker 当作提取工具。分析结束后,所有生成器和扫描器只消费 Typert 自有的 `WorkspaceModel`、`FaceModel` 与 `TypeGraph`,模型中不保留 AST 或 checker 对象。生成器不依赖 `@deepseek-ai/dsh-typert-registry`。
|
||||
|
||||
TypeGraph 保存开发者写下的计算前类型结构,包括泛型参数与应用、显式继承、conditional、mapped、递归引用和 JSDoc。无法无损表示的可达类型使分析失败;某个 emitter 无法处理已经建模的节点时由该 emitter 失败,而不是把类型展平或降级为 `unknown`。
|
||||
|
||||
每个 face 独立拥有 PackageModel 和 TypeGraph。`tsconfig.host.json` 与 `tsconfig.client.json` 的直接 project references 决定 package 的 face 归属,`package.json#exports` 决定公开边界。跨 face 关系只来自源码中的显式 import 或 re-export,并作为独立 link 保留;外部 npm 类型记录为 External,不读取或复制其声明。
|
||||
|
||||
PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@typert schema` 数据根。service 与 object 只暴露 public instance member,排除 constructor、static、private 和 protected;继承边保留在 TypeGraph 中,不复制为扁平成员。缺少 public property、parameter 或 return 类型标注时,`check` 模式报错,`write` 模式写入 checker 推断结果后重建 project 并再次以严格模式分析。
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。
|
||||
|
||||
包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
|
||||
|
||||
构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。
|
||||
|
||||
## Verification contract
|
||||
|
||||
提交内的小型双 face project 对完整类型模型及其源码声明索引做 snapshot。全仓分批分析与直接聚焦分析必须为相同 face 生成模型等价的 `FaceModel` 与 `TypeGraph`。类型级全集和运行时集合比较保证每种 node、target、declaration 与 member discriminant 都来自真实 TypeScript syntax;字段语义矩阵覆盖所有 keyword、type operator、literal value 类目,以及泛型、参数、tuple、mapped modifier、import attributes、abstract、predicate 和 enum initializer 的各个状态。
|
||||
|
||||
`SyntaxZoo` 中每个 property 的源码类型经 TypeScript printer 标准化后,必须与 TypeGraph 渲染结果逐项相等,随后所有渲染 declaration 再交给 TypeScript 编译。这一层检查节点内部信息是否无损,包括无插值 template literal、带 type argument 的 type query 和受约束 `infer`,不以 discriminant 覆盖或代码覆盖率代替结构等价。
|
||||
|
||||
边界用例固定同 face 与跨 face 的显式包导入、跨 face 命名 re-export、精确 export alias、qualified `import()` link 和全局 `@types` External 归属,并拒绝 package 自有 TypeScript 诊断、相对路径越界、`package.json#exports` 之外的引用,以及尚无模型 target 的跨 face namespace re-export。interface declaration merging 显式保留每个 authored part,无法无损表示的其他 merge 失败。
|
||||
|
||||
Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行 JavaScript,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**直接保存 TypeScript AST。** AST 能保留源码写法,但会让每个消费者依赖 compiler 生命周期、node identity 和 checker 上下文,无法形成稳定的架构边界,因此只在提取阶段使用。
|
||||
|
||||
**基于 checker 的最终类型生成。** 展平后的 `ts.Type` 便于直接遍历,却丢失泛型、conditional、mapped 和 alias application 的开发者表达,无法满足反射与后续生成需要。
|
||||
|
||||
**合并 host/client project 或复制 host 类型。** 合并会污染 Cordis declaration merging;复制会产生第二份类型事实源。独立 face 加显式 cross-face link 保留了 project 隔离与真实引用关系。
|
||||
|
||||
**让 `dsh-typert-registry` 承担类型解析和跨包合成。** 这会把 TypeScript compiler、Cordis 生命周期和具体 schema 策略重新耦合。注册表保持为生成 artifact 的生命周期容器,复杂分析留在构建期模型。
|
||||
|
||||
## Consequences
|
||||
|
||||
新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
|
||||
|
||||
显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md
|
||||
2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe
|
||||
2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348
|
||||
2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621
|
||||
|
||||
@@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](
|
||||
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`:
|
||||
|
||||
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
|
||||
### Outcome → Decision 映射
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md
|
||||
2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c
|
||||
2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11
|
||||
2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857
|
||||
2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb
|
||||
|
||||
@@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not
|
||||
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs.
|
||||
|
||||
**Shared (here):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop.
|
||||
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
|
||||
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
|
||||
@@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
|
||||
|
||||
## Consequences
|
||||
|
||||
Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
|
||||
@@ -15,7 +15,7 @@ Status: implemented
|
||||
在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。
|
||||
|
||||
**共享(本库):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。
|
||||
- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
|
||||
- **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
@@ -29,4 +29,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-05-skill-system.md
|
||||
2026-07-05-skill-system.md: 4fc621a9fdfa8042ebf3eb1975f0930cb1bf116c
|
||||
2026-07-05-skill-system.zh.md: 0dbebd211a1fa9e434d3f0a189c936f2b1c76574
|
||||
2026-07-05-skill-system.md: 242650ec8ba64fd0801a958711d5790fae07b259
|
||||
2026-07-05-skill-system.zh.md: da5f4af4b2f8bc0144be0a7ed608de7edd9a9947
|
||||
|
||||
@@ -18,13 +18,13 @@ Provider plugins register synchronously during `apply()`. Provider membership is
|
||||
|
||||
The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured.
|
||||
|
||||
Each skill is either `<name>/SKILL.md` or `<name>.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset.
|
||||
Each skill is either `<name>/SKILL.md` or `<name>.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset.
|
||||
|
||||
Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations.
|
||||
|
||||
`dsh-tool-skill` injects one durable user-role `<system-reminder>` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.)
|
||||
|
||||
The `skill({ name })` tool loads one full skill for the current agent cwd and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation` retain distinct tool errors. The tool result is the model-visible disclosure path.
|
||||
The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills with `invocation.modelInvocable: false` retain distinct tool errors. The tool result is the model-visible disclosure path.
|
||||
|
||||
The data structures and catalog/tool contract are documented in [skills.md](../../../../docs/core-data-structures/skills.md), with service signatures in the generated [services catalog](../../../../docs/cordis-catalog/services.md).
|
||||
|
||||
@@ -52,4 +52,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis
|
||||
|
||||
## Deferred
|
||||
|
||||
Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:<name>` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md).
|
||||
Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation ships as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md).
|
||||
|
||||
@@ -18,13 +18,13 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和
|
||||
|
||||
本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。
|
||||
|
||||
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
|
||||
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
|
||||
|
||||
`dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `<system-reminder>` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。)
|
||||
|
||||
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
|
||||
注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
|
||||
|
||||
数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。
|
||||
|
||||
@@ -52,4 +52,4 @@ agent-core 主干包含一个目录贡献者、一个本地提供方和一个面
|
||||
|
||||
## 延后
|
||||
|
||||
Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段,`user-invocable` frontmatter 字段同样不会被解析。直接用户调用本身则作为消费方层面的能力交付:TUI 前门基于注册表现有的 `list()` 与 `get()` 方法提供手动 `/skill:<name>` 命令,无需变更注册表、提供方或工具契约——见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。
|
||||
Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。
|
||||
|
||||
@@ -1,6 +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-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960
|
||||
2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
|
||||
2026-07-21-tui-skill-slash-command.md: 872e1f109728731e0d55e81a538c81e794724856
|
||||
2026-07-21-tui-skill-slash-command.zh.md: 772e25745ea7ab25f715208a9c6b1d10cf0c6e65
|
||||
|
||||
@@ -10,17 +10,17 @@ The [skill system](2026-07-05-skill-system.md) shipped with model-initiated load
|
||||
|
||||
## Decision
|
||||
|
||||
The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill:<name> [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `<skill name="…">` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract.
|
||||
The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill:<name> [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `<skill name="…">` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool. Its visibility and loading policy comes from the shared [independent model and user skill invocation policy](2026-07-28-skill-invocation-policy.md).
|
||||
|
||||
The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:<name>` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands.
|
||||
|
||||
Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything.
|
||||
Autocomplete filters the invocation-neutral `list()` result with `isUserInvocable`, and manual submission applies the same predicate after trusted `get()` resolves the definition. A user-only skill can therefore appear and load even when model invocation is disabled, while a user-disabled skill is neither advertised nor loadable by exact name. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, a user-disabled name, and a lookup failure each surface as a transcript notice without sending anything.
|
||||
|
||||
`renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in `<skill_content>`/`<skill_resources>`/`<skill_instructions>` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers.
|
||||
**Add a `user-invocable` frontmatter field only inside the original TUI change.** Rejected there because a TUI-only field would have changed the registry, provider, and tool contract without a shared invocation model. The later [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md) adds it across every relevant consumer and preserves `get()` as a trusted primitive.
|
||||
|
||||
**Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence.
|
||||
|
||||
|
||||
@@ -10,17 +10,17 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill:<name> [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `<skill name="…">` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。
|
||||
[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill:<name> [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `<skill name="…">` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的功能;它不新增任何面向模型的工具。其可见性和加载策略来自共享的[模型与用户独立 skill 调用策略](2026-07-28-skill-invocation-policy.md)。
|
||||
|
||||
TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:<name>` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。
|
||||
|
||||
自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。
|
||||
自动补全使用 `isUserInvocable` 过滤与调用策略无关的 `list()` 结果;手动提交则在受信的 `get()` 解析定义后应用相同判定。因此,即使模型调用已禁用,仅供用户调用的 skill 仍会显示并可加载;用户禁用的 skill 既不会展示,也无法按精确名称加载。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、用户禁用的名称以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。
|
||||
|
||||
`renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 `<skill_content>`/`<skill_resources>`/`<skill_instructions>` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。
|
||||
**仅在最初的 TUI 变更内新增 `user-invocable` frontmatter 字段。** 当时未采纳,因为 TUI 独有的字段会在没有共享调用模型的情况下改变注册表、提供方和工具契约。后续的[独立调用策略决策](2026-07-28-skill-invocation-policy.md)将其扩展到每个相关消费方,并保留 `get()` 作为受信原语。
|
||||
|
||||
**把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md
|
||||
2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140
|
||||
2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Independent model and user skill invocation policy
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-skill-invocation-policy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through `get()`.
|
||||
|
||||
The local parser also exposed an internal camel-case spelling as frontmatter. Supporting the established negative `disable-model-invocation` and positive `user-invocable` fields requires a durable, symmetric domain representation without turning every possible YAML key into an untyped cross-package contract.
|
||||
|
||||
## Decision
|
||||
|
||||
`SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields and their defaults into the normalized typed policy.
|
||||
|
||||
`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. The model tool and TUI check the invocation-neutral summary before calling `get()`, then recheck the loaded definition so a denied name never reaches definition loading and a policy change between discovery and load cannot expose its body.
|
||||
|
||||
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation.
|
||||
|
||||
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
|
||||
|
||||
These rules permit all four combinations:
|
||||
|
||||
| Policy | Model surface | User surface |
|
||||
|---|---|---|
|
||||
| `{ modelInvocable: true, userInvocable: true }` | included | included |
|
||||
| `{ modelInvocable: true, userInvocable: false }` | included | excluded |
|
||||
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
|
||||
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |
|
||||
|
||||
This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Store all frontmatter in a generic `Map` and read string keys in `isModelInvocable` / `isUserInvocable`.** Rejected because misspelled keys, non-boolean values, and consumer-specific coercion would cross package seams without type checking. The parser boundary remains open; the domain model is deliberately typed and narrow.
|
||||
|
||||
**Keep `ctx.skills.list()` model-filtered and add a second user list.** Rejected because discovery, duplicate resolution, caching, and ordering are surface-neutral work. One complete catalog plus explicit predicates prevents those mechanisms from drifting while making each consumer's policy visible at its boundary.
|
||||
|
||||
**Enforce invocation policy inside `ctx.skills.get()`.** Rejected because `get()` cannot know whether its caller is a model tool, a human command, or trusted orchestration. Filtering there would also make the both-disabled quadrant impossible to inspect or administer.
|
||||
|
||||
**Treat camel-case frontmatter as an alias.** Rejected because the external format is the kebab-case Claude skills contract and the repository has no released compatibility obligation. Failing loud avoids silently preserving a nonstandard spelling.
|
||||
|
||||
**Add a browser-side direct skill invocation RPC.** Rejected for this change because the existing browser flow inserts a model reference rather than a loaded instruction body. Its correct policy is therefore the intersection; a direct user-loading surface needs its own wire and logging design.
|
||||
|
||||
## Consequences
|
||||
|
||||
Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior.
|
||||
|
||||
The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. The assembled keyless TUI snapshot discovers and loads a user-only skill by exact name, then rejects a model-only skill before loading its body; the real Loader/PTY smoke proves the same user-only path through the shipped terminal process. The real-host Chromium snapshot pins the browser intersection across all four policy quadrants. TUI unit coverage exercises those quadrants plus disposal races, while registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 模型与用户彼此独立的 skill(技能)调用策略
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-skill-invocation-policy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill,而 `ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。
|
||||
|
||||
本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation` 和正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。
|
||||
|
||||
## 决策
|
||||
|
||||
`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
|
||||
|
||||
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 `get()` 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。
|
||||
|
||||
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。
|
||||
|
||||
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
|
||||
|
||||
这些规则允许以下四种组合:
|
||||
|
||||
| 策略 | 模型侧接口 | 用户侧接口 |
|
||||
|---|---|---|
|
||||
| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
|
||||
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
|
||||
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
|
||||
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
|
||||
|
||||
该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将所有 frontmatter 存入通用 `Map`,并在 `isModelInvocable` / `isUserInvocable` 中读取字符串键。** 不予采纳,因为拼写错误的键、非布尔值以及各消费方自行采用的类型转换都会越过包边界,且无法获得类型检查。解析器边界仍保持开放;领域模型则有意采用类型明确的窄接口。
|
||||
|
||||
**保持 `ctx.skills.list()` 仅返回允许模型调用的 skill,并另增一份用户列表。** 不予采纳,因为发现、重复项解析、缓存和排序都是与调用接口无关的工作。采用一份完整目录和显式判定函数,可以避免这些机制逐渐分化,并在各消费方边界清楚呈现其策略。
|
||||
|
||||
**在 `ctx.skills.get()` 内执行调用策略。** 不予采纳,因为 `get()` 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。
|
||||
|
||||
**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 契约,而本仓库尚未发布,无需承担兼容义务。快速失败可以避免暗中保留不符合标准的拼写。
|
||||
|
||||
**增加由浏览器端直接调用 skill 的 RPC。** 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。
|
||||
|
||||
## 后果
|
||||
|
||||
提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。
|
||||
|
||||
无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及资源释放竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5
|
||||
|
||||
@@ -10,18 +10,22 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo
|
||||
|
||||
## Decision
|
||||
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.**
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.**
|
||||
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.
|
||||
|
||||
**Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat.
|
||||
|
||||
**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate.
|
||||
|
||||
**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
|
||||
|
||||
**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.
|
||||
|
||||
## Consequences
|
||||
|
||||
Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome.
|
||||
Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome.
|
||||
|
||||
@@ -10,18 +10,22 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
|
||||
|
||||
## 决策
|
||||
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。**
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。**
|
||||
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
|
||||
## 曾考虑的方案
|
||||
|
||||
**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
|
||||
|
||||
**给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。
|
||||
|
||||
**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
|
||||
|
||||
**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
|
||||
|
||||
**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。
|
||||
|
||||
## 后果
|
||||
|
||||
已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
|
||||
@@ -1,6 +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-06-11-quality-gates.md: e1af110387936d644208dc1829fde4a4fdf8a3f9
|
||||
2026-06-11-quality-gates.zh.md: a4e57b7a08ecf20babb33b55d8c94414df1b10b1
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-quality-gates.md
|
||||
2026-06-11-quality-gates.md: 60db7ba5cfa8184c0fcce764aa027f32a9b721ab
|
||||
2026-06-11-quality-gates.zh.md: a5ac7cd831255d479f7a1d75586785e546877fba
|
||||
|
||||
@@ -15,11 +15,11 @@ This codebase is developed primarily by coding agents. Agents follow enforced ga
|
||||
Every mechanically checkable AGENTS.md promise gets a command that exits non-zero. CI invokes the exhaustive set, while Git hooks reserve their latency budget for cheap local defects:
|
||||
|
||||
- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary.
|
||||
- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded.
|
||||
- [Oxlint](2026-07-29-oxlint-linter.md) with type-aware TypeScript rules plus the @stylistic and SonarJS compatibility plugins, enforcing the house style and file-local duplicated-logic checks; vendored code excluded.
|
||||
- jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations.
|
||||
- Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
|
||||
- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
|
||||
- lefthook pre-commit fixes staged lint, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
- lefthook pre-commit applies formatting-only ESLint fixes before Oxlint validation and native fixes, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ Status: implemented
|
||||
每条可机械检查的 AGENTS.md 承诺都有一个以非零状态退出的命令。CI 执行完整集合,而 Git 钩子将延迟预算留给可低成本发现的本地缺陷:
|
||||
|
||||
- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而包(package)/vendor 代码保持在各自 project-reference 边界之后。
|
||||
- ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。
|
||||
- [Oxlint](2026-07-29-oxlint-linter.md) 配合类型感知的 TypeScript 规则以及 @stylistic 和 SonarJS 兼容插件,强制执行统一代码风格和文件内重复逻辑检查;vendor 代码排除在外。
|
||||
- jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。
|
||||
- `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。
|
||||
- knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。
|
||||
- lefthook pre-commit 修复已暂存文件的 lint 问题、拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。
|
||||
- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
|
||||
2026-07-06-parallel-pre-push-gates.md: d86642b7feb82908ec792db0c6a3da403cfc79fd
|
||||
2026-07-06-parallel-pre-push-gates.zh.md: 0425cf1a01b56604a07be366dd46d3920c5fb487
|
||||
2026-07-06-parallel-pre-push-gates.md: 538e52c5318fb6d4eab2e8786513a08c1ff0ec55
|
||||
2026-07-06-parallel-pre-push-gates.zh.md: e93eec8757c20c8154703d9bdfd2f0c805e6a26c
|
||||
|
||||
@@ -14,7 +14,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains
|
||||
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
|
||||
|
||||
The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that ESLint must not traverse; source compatibility checks can overlap the validation chain.
|
||||
The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that the linter must not traverse; source compatibility checks can overlap the validation chain.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages/<group>/<pkg>` 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.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。
|
||||
|
||||
Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 ESLint 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。
|
||||
Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: 983d5520bd73fc3cf82c37bf0d4a9ff1c6e6f51c
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: a86dcf2c60d7b950e7557e84ef6993e712a2ce09
|
||||
|
||||
@@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos
|
||||
|
||||
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
|
||||
|
||||
Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
|
||||
Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
|
||||
|
||||
The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
|
||||
|
||||
Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
|
||||
Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
|
||||
|
||||
门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。
|
||||
|
||||
|
||||
@@ -1,6 +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-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md
|
||||
2026-07-22-fast-local-git-hooks.md: 838024c4293372b1430d357774feb06cd9742b9b
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 460acf5270c075a808c6a4dc42635a808c7cd192
|
||||
|
||||
@@ -12,7 +12,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode.
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, [Oxlint](2026-07-29-oxlint-linter.md) validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode.
|
||||
|
||||
Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction.
|
||||
|
||||
@@ -27,10 +27,10 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202
|
||||
- **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication.
|
||||
- **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits.
|
||||
- **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary.
|
||||
- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`.
|
||||
- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the auto-fix workflow; the formatting-only pass and Lefthook's `stage_fixed` preserve it without making ESLint a repository correctness runner or duplicating `git add`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
Normal commits take the staged formatter-and-lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
|
||||
Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision.
|
||||
|
||||
@@ -12,7 +12,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查,
|
||||
|
||||
## 决策
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。
|
||||
[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,[Oxlint](2026-07-29-oxlint-linter.md) 验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。
|
||||
|
||||
两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。
|
||||
|
||||
@@ -27,10 +27,10 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小
|
||||
- **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。
|
||||
- **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。
|
||||
- **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。
|
||||
- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。
|
||||
- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留自动修复工作流;仅用于格式化的流程和 Lefthook 的 `stage_fixed` 会保留该工作流,而不会让 ESLint 成为仓库正确性检查运行器,也无需重复执行 `git add`。
|
||||
|
||||
## 结果
|
||||
|
||||
普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
普通提交的关键路径是暂存文件格式化与 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
|
||||
从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。
|
||||
|
||||
@@ -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 .agents/notes/implemented/process/2026-07-29-oxlint-linter.md
|
||||
2026-07-29-oxlint-linter.md: 41a50a9d08819809f954aa99007081f270692f38
|
||||
2026-07-29-oxlint-linter.zh.md: 1ad72a00cb921ed688363583d56634f52b355b4e
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Oxlint as the repository linter
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-oxlint-linter.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The repository needs type-aware TypeScript correctness rules, consistent formatting, and file-local duplicate-logic checks across its owned source. ESLint supplied those checks through a JavaScript parser, a project service, and multiple plugins, but a clean lint run spent about one minute on the local migration baseline and required an 8 GiB Node heap, CI result caches, and separately tuned ESLint concurrency.
|
||||
|
||||
A faster runner cannot justify losing rules. The migration must preserve the strict type-checked preset, repository overrides, inline suppressions, @stylistic fixes, SonarJS checks, host/client TypeScript separation, and the vendor exclusion.
|
||||
|
||||
## Decision
|
||||
|
||||
The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative repository lint configuration. The `lint` package script, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) for repository-wide, type-aware, or staged validation. The `lint:fix` script and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs), then run Oxlint. The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; their exact versions pin the tested parser/fixer pairing, and that config contains no correctness or type-aware rules.
|
||||
|
||||
`options.typeAware` enables `oxlint-tsgolint`. Its backend performs per-file TypeScript-project discovery: package sources use their package projects, host tests/examples/website use `tsconfig.host.json`, and client tests plus `scripts/client-bundle-purity.spec.ts` use `tsconfig.client.json`. The program-less root solution is never flattened. Oxlint's `--tsconfig` override affects import resolution but is ignored by type-aware linting, so this repository does not set it. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration.
|
||||
|
||||
Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations but does not execute their fixers, so the formatting-only ESLint pass owns only the corresponding auto-fixes; an executable parity check keeps those fixable rule definitions aligned while `max-len` remains validation-only. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace, and unused directives remain warnings; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`.
|
||||
|
||||
CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs Oxlint validation and native safe fixes, accepts selections containing only ignored files, and re-stages the result through lefthook.
|
||||
|
||||
## Verification
|
||||
|
||||
The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A one-time audit against the exact deleted ESLint configuration blob established source 88-to-88, examples 87-to-87, and tests 83-to-83 after the rule-name translations. The committed fingerprint pins those audited Oxlint profiles and the complete override shape; it neither executes the deleted configuration nor propagates later upstream preset changes. Evaluating `typescript-eslint@8.61.0` also confirms that `strictTypeChecked` did not enable `@typescript-eslint/no-empty-function`; the deleted tests-only `off` entry was inert.
|
||||
|
||||
Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin unused-suppression reporting, ignored-only staged selections, formatter/validator rule parity, and final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Run both linters repository-wide.** Every correctness rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. A repository-wide ESLint fallback would preserve the slower project-service setup and two correctness configurations without adding a check; the retained ESLint pass is deliberately limited to project-free staged formatting.
|
||||
|
||||
**Rely on compatibility-layer fixes.** The layer reports the established `@stylistic` rules but does not apply their fixes under either Oxlint fix mode. Keeping the narrow staged formatter preserves the contributor contract without broadening ESLint back into a repository linter.
|
||||
|
||||
**Drop @stylistic or SonarJS rules that are not native.** This would remove dependencies but weaken the mechanical quality contract. The compatibility layer preserves those rules until native replacements can be evaluated as a separate decision.
|
||||
|
||||
**Replace @stylistic with Oxfmt during the migration.** A formatter migration would change output beyond the lint-engine boundary and create a repository-wide formatting diff. Keeping the established rules makes this change reviewable and leaves formatter selection independent.
|
||||
|
||||
## Consequences
|
||||
|
||||
Local migration measurements reduced a clean type-aware lint run from about 61 seconds to about 8 seconds without a result cache. The exact ratio is host-dependent and is not a performance guarantee.
|
||||
|
||||
Type-aware diagnostics now come from the TypeScript Go analyzer bundled through `oxlint-tsgolint`, so edge-case inference can differ from typescript-eslint even when `tsc` accepts the same program. Lint and typecheck remain separate required evidence.
|
||||
|
||||
The JavaScript-plugin compatibility API and staged formatter are additional boundaries to maintain. Commits pay one project-free ESLint startup before Oxlint, and the root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 使用 Oxlint 作为仓库 linter
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-oxlint-linter.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
仓库的自有源码需要类型感知的 TypeScript 正确性规则、一致的格式,以及文件内重复逻辑检查。ESLint 通过 JavaScript 解析器、项目服务和多个插件提供这些检查,但在本地迁移基线上,一次无问题的 lint 运行约需 1 分钟,并且需要 8 GiB Node 堆、CI 结果缓存和单独调优的 ESLint 并发度。
|
||||
|
||||
不能以提高运行速度为由丢失规则。迁移必须保留严格类型检查预设、仓库覆盖配置、内联抑制指令、@stylistic 修复、SonarJS 检查、host/client TypeScript 隔离和 vendor 排除规则。
|
||||
|
||||
## 决策
|
||||
|
||||
根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包(package)脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint,进行全仓库、类型感知或暂存验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。
|
||||
|
||||
`options.typeAware` 启用 `oxlint-tsgolint`。其后端按文件发现 TypeScript 项目:包源码使用各自的包项目,host 测试、示例和网站使用 `tsconfig.host.json`,client 测试及 `scripts/client-bundle-purity.spec.ts` 使用 `tsconfig.client.json`。不含程序的根解决方案绝不会被扁平化。Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。
|
||||
|
||||
Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规,但不会执行其修复器,因此仅用于格式化的 ESLint 流程只负责相应的自动修复;一项可执行检查确保这些可修复规则定义保持一致,而 `max-len` 仅用于验证。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间,未使用的指令仍作为警告报告;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。
|
||||
|
||||
CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行 Oxlint 验证和原生安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。
|
||||
|
||||
## 验证
|
||||
|
||||
解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。以已删除 ESLint 配置的精确 blob 为基准进行的一次性审核在完成规则名映射后确认:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项。已提交的指纹锁定这些经审核的 Oxlint 规则配置及完整的覆盖结构;它既不执行已删除的配置,也不纳入后续的上游预设变更。对 `typescript-eslint@8.61.0` 的评估还确认,`strictTypeChecked` 并未启用 `@typescript-eslint/no-empty-function`;已删除、仅用于测试的 `off` 条目不起作用。
|
||||
|
||||
可执行契约测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、格式化器与验证器之间的规则一致性,以及最终格式化后的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在全仓库范围内同时运行两个 linter。** 所有正确性规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。在全仓库范围启用 ESLint 回退会保留较慢的项目服务初始化和两套正确性配置,却不会增加任何检查;保留的 ESLint 流程被刻意限制为不加载项目的暂存文件格式化。
|
||||
|
||||
**依赖兼容层修复。** 兼容层会报告既有的 `@stylistic` 规则,但在 Oxlint 的两种修复模式下都不会应用这些规则的修复。保留窄范围的暂存文件格式化器,可以在不将 ESLint 扩张回仓库 linter 的情况下维持贡献者契约。
|
||||
|
||||
**移除尚无原生实现的 @stylistic 或 SonarJS 规则。** 这会移除依赖,但也会削弱机械质量契约。兼容层会保留这些规则,直到能够通过单独决策评估原生替代规则。
|
||||
|
||||
**迁移期间用 Oxfmt 替换 @stylistic。** 格式化器迁移会产生超出 lint 引擎边界的输出变化,并带来全仓库格式 diff。保留既有规则可使本次变更便于评审,并让格式化器选择保持独立。
|
||||
|
||||
## 结果
|
||||
|
||||
本地迁移测量显示,不使用结果缓存时,一次无问题的类型感知 lint 运行从约 61 秒缩短至约 8 秒。确切比例因主机而异,不构成性能保证。
|
||||
|
||||
类型感知诊断现在来自通过 `oxlint-tsgolint` 捆绑的 TypeScript Go 分析器,因此即使 `tsc` 接受同一程序,边界场景下的类型推断也可能与 typescript-eslint 不同。lint 与类型检查仍是两项相互独立的必要证据。
|
||||
|
||||
JavaScript 插件兼容 API 和暂存文件格式化器是需要维护的额外边界。每次提交在 Oxlint 之前需要启动一次不加载项目的 ESLint,根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。
|
||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -180,10 +180,9 @@ jobs:
|
||||
|| 'dsh-enterprise-ubuntu-latest-32core-test' }}
|
||||
name: node 24 / snapshots and artifacts
|
||||
env:
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_ESLINT_CONCURRENCY: '8'
|
||||
DSH_GATE_CONCURRENCY: '8'
|
||||
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
|
||||
DSH_OXLINT_THREADS: '8'
|
||||
DSH_PUBLINT_CONCURRENCY: '8'
|
||||
# Failover halves snapshot concurrency for the shared 64-core VM.
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
|
||||
@@ -200,13 +199,6 @@ jobs:
|
||||
- name: Restore built tree
|
||||
run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
|
||||
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
@@ -443,7 +435,7 @@ jobs:
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Master refreshes the caches that pull requests restore without saving.
|
||||
# Master refreshes the pnpm store cache that pull requests restore without saving.
|
||||
# The store cache stays a hand-rolled actions/cache step rather than
|
||||
# setup-node's `cache: pnpm`: the enterprise pull-request jobs above
|
||||
# restore exactly this key and path, and setup-node's built-in cache
|
||||
@@ -456,13 +448,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -474,8 +459,8 @@ jobs:
|
||||
DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_OXLINT_THREADS: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
@@ -528,8 +513,8 @@ jobs:
|
||||
DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_OXLINT_THREADS: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
@@ -582,15 +567,6 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
# Master refreshes the small cache that pull requests restore without
|
||||
# putting package-store extraction back on the Windows critical path.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -600,8 +576,8 @@ jobs:
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_OXLINT_THREADS: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
@@ -776,14 +752,6 @@ jobs:
|
||||
console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
|
||||
memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.platform == 'linux'
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- name: Install and prepare Linux
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
@@ -807,9 +775,8 @@ jobs:
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }}
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_ESLINT_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_OXLINT_THREADS: ${{ matrix.workers }}
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }}
|
||||
run: pnpm run check:ci
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,6 +13,9 @@ examples/*/.sessions/
|
||||
coverage/
|
||||
.doc-typecheck-*/
|
||||
.node-next-types-*/
|
||||
.oxlint-contract-*/
|
||||
.oxlintrc.contract-*.json
|
||||
oxlint-contract-*.ts
|
||||
.humanize/
|
||||
tmp/
|
||||
.claude/commands/
|
||||
@@ -33,3 +36,4 @@ apps/web/dist/
|
||||
.worktrees/
|
||||
worktrees/
|
||||
.agents/worktrees/
|
||||
.typert-*/
|
||||
|
||||
303
.oxlintrc.json
Normal file
303
.oxlintrc.json
Normal file
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "warn",
|
||||
"typeAware": true
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/lib/**",
|
||||
"**/node_modules/**",
|
||||
"**/.sessions/**",
|
||||
".claude/**", // Harness-local state belongs to other checkouts, not this checkout's sources.
|
||||
"**/.doc-typecheck-*/**",
|
||||
"**/.node-next-types-*/**",
|
||||
"**/.oxlint-contract-*/**", // Scratch files created by the executable lint-contract tests.
|
||||
"**/oxlint-contract-*", // Flat probes use real TypeScript project include paths.
|
||||
"packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run.
|
||||
"website/.generated/**",
|
||||
"vendor/**", // Vendored source keeps upstream style and idioms.
|
||||
"native/**", // The imported landlock-run subtree has its own gates; see native/README.md.
|
||||
"**/*.js",
|
||||
"**/*.mjs",
|
||||
"**/*.config.ts", // Tool and app configs are outside the repository TypeScript programs.
|
||||
"packages/client/tsdown.client.ts" // Shared client build preset, also outside a TypeScript program.
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
// Shared strict type-aware rules. Source/test differences stay in the short overrides below.
|
||||
"files": [
|
||||
"packages/*/*/src/**/*.{ts,tsx}",
|
||||
"packages/*/*/tests/**/*.{ts,tsx}",
|
||||
"apps/*/src/**/*.{ts,tsx}",
|
||||
"apps/*/tests/**/*.{ts,tsx}",
|
||||
"examples/**/*.{ts,tsx}",
|
||||
"scripts/**/*.{ts,tsx}",
|
||||
"website/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-useless-constructor": "error",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/ban-ts-comment": [
|
||||
"error",
|
||||
{
|
||||
"minimumDescriptionLength": 10
|
||||
}
|
||||
],
|
||||
"typescript/no-array-delete": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-confusing-void-expression": "error",
|
||||
"typescript/no-deprecated": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-duplicate-type-constituents": "error",
|
||||
"typescript/no-dynamic-delete": "error",
|
||||
"typescript/no-empty-object-type": "off", // Merge-extensible maps intentionally use empty object types.
|
||||
"typescript/no-explicit-any": "error", // Every intentional any needs a narrow suppression with rationale.
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-extraneous-class": "error",
|
||||
// Lost promises in the agent loop are the repository's highest-value linted bug class.
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-invalid-void-type": "off", // Event signatures intentionally use void in source.
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-misused-spread": "error",
|
||||
"typescript/no-mixed-enums": "error",
|
||||
"typescript/no-namespace": "off", // Cordis Config namespaces are the repository idiom.
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-redundant-type-constituents": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "error",
|
||||
"typescript/no-unnecessary-template-expression": "error",
|
||||
"typescript/no-unnecessary-type-arguments": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unnecessary-type-conversion": "error",
|
||||
"typescript/no-unnecessary-type-parameters": "error",
|
||||
"typescript/no-unsafe-argument": "error",
|
||||
"typescript/no-unsafe-assignment": "error",
|
||||
"typescript/no-unsafe-call": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-enum-comparison": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unsafe-member-access": "error",
|
||||
"typescript/no-unsafe-return": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-useless-default-assignment": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-literal-enum-member": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-promise-reject-errors": "error",
|
||||
"typescript/prefer-reduce-type-parameter": "error",
|
||||
"typescript/prefer-return-this-type": "error",
|
||||
"typescript/related-getter-setter-pairs": "error",
|
||||
"typescript/restrict-plus-operands": [
|
||||
"error",
|
||||
{
|
||||
"allowAny": false,
|
||||
"allowBoolean": false,
|
||||
"allowNullish": false,
|
||||
"allowNumberAndString": false,
|
||||
"allowRegExp": false
|
||||
}
|
||||
],
|
||||
"typescript/return-await": [
|
||||
"error",
|
||||
"error-handling-correctness-only"
|
||||
],
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/unified-signatures": "error",
|
||||
"typescript/use-unknown-in-catch-callback-variable": "error",
|
||||
"no-void": "off" // void foo() marks deliberate fire-and-forget arrow listeners.
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/*/*/src/**/*.{ts,tsx}",
|
||||
"apps/*/src/**/*.{ts,tsx}",
|
||||
"examples/**/*.{ts,tsx}",
|
||||
"scripts/**/*.{ts,tsx}",
|
||||
"website/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"typescript/no-unnecessary-condition": [
|
||||
"error",
|
||||
{
|
||||
"allowConstantLoopConditions": true
|
||||
}
|
||||
],
|
||||
"typescript/only-throw-error": "error",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/restrict-template-expressions": [
|
||||
"error",
|
||||
{
|
||||
"allowNumber": true,
|
||||
"allowBoolean": true
|
||||
}
|
||||
],
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{
|
||||
"considerDefaultExhaustiveForUnions": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"examples/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/require-await": "off" // Demo callbacks conform to async interfaces without awaiting.
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/*/*/tests/**/*.{ts,tsx}",
|
||||
"apps/*/tests/**/*.{ts,tsx}",
|
||||
"examples/*/tests/**/*.{ts,tsx}",
|
||||
"scripts/**/*.spec.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-invalid-void-type": "error",
|
||||
"typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence.
|
||||
"typescript/no-unnecessary-condition": "off",
|
||||
"typescript/only-throw-error": "off", // Tests deliberately exercise non-Error throws.
|
||||
"typescript/require-await": "off", // Mock execute() implementations must retain async signatures.
|
||||
"typescript/restrict-template-expressions": "off"
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/**/*.{ts,tsx}",
|
||||
"apps/**/*.{ts,tsx}",
|
||||
"examples/**/*.{ts,tsx}",
|
||||
"scripts/**/*.{ts,tsx}",
|
||||
"website/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"sonarjs/duplicates-in-character-class": "error",
|
||||
"sonarjs/no-all-duplicated-branches": "error",
|
||||
"sonarjs/no-duplicate-in-composite": "error",
|
||||
"sonarjs/no-duplicate-test-title": "error",
|
||||
"sonarjs/no-identical-conditions": "error",
|
||||
"sonarjs/no-identical-expressions": "error",
|
||||
"sonarjs/no-identical-functions": "error",
|
||||
"sonarjs/no-duplicated-branches": "error"
|
||||
},
|
||||
"jsPlugins": [
|
||||
"eslint-plugin-sonarjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/**/*.{ts,tsx}",
|
||||
"apps/**/*.{ts,tsx}",
|
||||
"examples/**/*.{ts,tsx}",
|
||||
"scripts/**/*.{ts,tsx}",
|
||||
"website/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indent": [
|
||||
"error",
|
||||
2
|
||||
],
|
||||
"@stylistic/semi": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"@stylistic/comma-dangle": [
|
||||
"error",
|
||||
"always-multiline"
|
||||
],
|
||||
"@stylistic/eol-last": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/object-curly-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"@stylistic/arrow-parens": [
|
||||
"error",
|
||||
"as-needed",
|
||||
{
|
||||
"requireForBlockBody": true
|
||||
}
|
||||
],
|
||||
"@stylistic/member-delimiter-style": [
|
||||
"error",
|
||||
{
|
||||
"multiline": {
|
||||
"delimiter": "none"
|
||||
},
|
||||
"singleline": {
|
||||
"delimiter": "semi",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
// Validation-only: line length has no safe formatter fix.
|
||||
"@stylistic/max-len": [
|
||||
"error",
|
||||
{
|
||||
"code": 140,
|
||||
"ignoreUrls": true,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"jsPlugins": [
|
||||
"@stylistic/eslint-plugin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every
|
||||
vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md
|
||||
packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
core/ product API spine: session, system-prompt, tools, agent, agent-loop
|
||||
typert/ type graph generator, loader, and runtime registry
|
||||
llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
|
||||
bash/ bash executor seam + local impl + model-facing bash tools
|
||||
subprocess/ subprocess seam + local process-tree impl
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
// Session row actions in the assembled fixture app: Rename opens the
|
||||
// browser-owned dialog and settles the title from the unary response.
|
||||
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
|
||||
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
|
||||
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
|
||||
// assembles — staged activation across the immediately tier and the inject
|
||||
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||
// content from the keyless FixtureApiClient transport.
|
||||
//
|
||||
// Behavior assertions do NOT belong here: component and wiring behavior is
|
||||
// pinned by the per-package suites (SlotTestRuntime benches over src), which
|
||||
// this smoke's plugin set cannot influence — bundling, module-table
|
||||
// resolution, and boot layering are the only failure modes left to it.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -16,9 +25,18 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
@@ -42,16 +60,11 @@ let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
document.title = 'DeepSeek Harness'
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,7 +73,6 @@ afterEach(() => {
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
@@ -68,9 +80,12 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function bootApp(): Promise<void> {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
it('boots the built plugin graph and renders a fixture session end to end', async () => {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
@@ -82,49 +97,21 @@ async function bootApp(): Promise<void> {
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
/** The session row element carrying the given visible label. */
|
||||
function rowOf(label: string): HTMLElement {
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error(`session row "${label}" missing`)
|
||||
return row
|
||||
}
|
||||
// The sidebar renders from the boot graph: every inject layer activated.
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
/** Open the row's ... menu and click one action. The anchor button is
|
||||
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
|
||||
* so role queries filter it as hidden); target it directly. */
|
||||
function pickRowAction(label: string, action: string): void {
|
||||
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
|
||||
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
|
||||
fireEvent.click(anchor)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
|
||||
}
|
||||
// Opening a session reaches chat content through the fixture transport.
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
|
||||
await bootApp()
|
||||
const sourceLabel = 'Fixture 历史会话'
|
||||
await screen.findByText(sourceLabel)
|
||||
|
||||
pickRowAction(sourceLabel, 'Rename')
|
||||
const input = await screen.findByLabelText('Session name')
|
||||
expect((input as HTMLInputElement).value).toBe(sourceLabel)
|
||||
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
|
||||
// Host-side normalization collapses whitespace; the dialog closes on
|
||||
// acceptance and the row re-labels without any push-frame wait.
|
||||
const renamed = '分叉 实验记录'
|
||||
await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
|
||||
await screen.findByText(renamed)
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
expect(within(tree).queryByText(sourceLabel)).toBeNull()
|
||||
|
||||
const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
|
||||
label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
|
||||
}))
|
||||
await expect(`${JSON.stringify(rows, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
|
||||
expect(styleOwners).toContain(plugin)
|
||||
}
|
||||
})
|
||||
@@ -1,239 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session and pins the run_code turn's rendering:
|
||||
// the code-variant parent row titled by the model-authored description, its
|
||||
// three always-visible nested sub-rows (bash through the sample registration,
|
||||
// read through GenericToolCard, the failing read wearing the error state),
|
||||
// the expanded program body, inert bash / file-link sub-row gestures,
|
||||
// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call
|
||||
// cells and timing overview.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against the populated fixture branch. */
|
||||
function boot(): void {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('4 sessions').closest<HTMLElement>('[role="treeitem"]')
|
||||
if (group === null) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
const session = await within(tree).findByText('Fixture 历史会话')
|
||||
fireEvent.click(session)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
||||
const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
|
||||
if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
|
||||
|
||||
expect({
|
||||
parentRow: visibleText(codeRoot),
|
||||
// The three sub-rows in dispatch order: bash rides the sample plugin's
|
||||
// keyed registration (the same one a native top-level bash row uses),
|
||||
// both reads ride GenericToolCard.
|
||||
bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
|
||||
subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
|
||||
errorSubRow: nest.querySelector('[data-state="error"]') !== null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bashSample": true,
|
||||
"errorSubRow": true,
|
||||
"parentRow": "CodeRead the notes files and summarize",
|
||||
"subRows": [
|
||||
"BashList notes",
|
||||
"Readnotes/demo.txt",
|
||||
"Readnotes/missing.txt",
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('expands the code row into the program body; sub-row clicks do not open details', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
// Expand: the leading control reveals the program (shiki-tokenized: the
|
||||
// text splits into styled spans inside one <pre class="shiki"> tree).
|
||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
||||
const toggle = codeRoot.querySelector('button[aria-expanded]')
|
||||
if (toggle === null) throw new Error('code row expand control missing')
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => {
|
||||
// Scope to THIS row: the markdown fixture turn also renders shiki pres.
|
||||
const pre = codeRoot.querySelector('pre.shiki')
|
||||
if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
|
||||
throw new Error('highlighted program body missing under the code row')
|
||||
}
|
||||
})
|
||||
|
||||
// Tool rows no longer drive the details panel: bash is inert, file paths
|
||||
// are host-open links (fixture openPath is a no-op success).
|
||||
const nest = document.querySelector('[data-subcalls]')
|
||||
if (nest === null) throw new Error('sub-call nest missing')
|
||||
const bashRow = nest.querySelector('[data-sample="bash-global"]')
|
||||
if (bashRow === null) throw new Error('bash sample sub-row missing')
|
||||
const fileLink = nest.querySelector('button')
|
||||
if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
|
||||
const frame = document.querySelector('[data-details-collapsed]')
|
||||
if (frame === null) throw new Error('app frame missing')
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(bashRow)
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(fileLink)
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
expect({
|
||||
fileLink: visibleText(fileLink),
|
||||
detailsCollapsed: frame.getAttribute('data-details-collapsed'),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"detailsCollapsed": "true",
|
||||
"fileLink": "notes/demo.txt",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
// Switch to the trajectory tab (same slot ring the chat view registers in).
|
||||
fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
|
||||
expect({
|
||||
// Three Subtool cells nested under the run_code Tool cell in dispatch
|
||||
// order, each paired with its result preview.
|
||||
subCells: subCells.map(cell => visibleText(cell)),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"subCells": [
|
||||
"SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
|
||||
"SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
|
||||
"SUBTOOLread{"path":"notes/missing.txt"}→error",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
|
||||
expect({
|
||||
count: timelineSubCalls.length,
|
||||
measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"count": 3,
|
||||
"measured": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureTiming {
|
||||
appendTitle(id: string, title: string): void
|
||||
}
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
document.title = 'DeepSeek Harness'
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Read only the stable, user-facing title surfaces from the assembled app. */
|
||||
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const sidebar = within(tree).getByText(label).textContent ?? ''
|
||||
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
|
||||
.getByRole('button', { name: label }).textContent ?? ''
|
||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
||||
}
|
||||
|
||||
it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
// The fixture Intent selects the workspace, so the current-group effect
|
||||
// already expanded it; clicking the header would now collapse (the twist
|
||||
// stays live since intent stopped forcing expansion).
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
const initialLabel = 'Fixture 历史会话'
|
||||
const initialRowLabel = await screen.findByText(initialLabel)
|
||||
const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (initialRow === null) throw new Error('fixture session row missing')
|
||||
fireEvent.click(initialRow)
|
||||
await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
|
||||
const initial = titleSurfaces(initialLabel)
|
||||
|
||||
const revisedLabel = 'Fixture 修订标题'
|
||||
const timing = (globalThis as Record<string, unknown>).__fxTiming as FixtureTiming
|
||||
act(() => { timing.appendTitle('fx-alpha', revisedLabel) })
|
||||
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
|
||||
const revised = titleSurfaces(revisedLabel)
|
||||
|
||||
// fx-alpha carries the fixture's resident answerable approval, so the
|
||||
// approval panel has taken over the composer (the real takeover behavior);
|
||||
// answer it to restore the composer chrome before asserting the model seat.
|
||||
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
|
||||
const modelTrigger = await screen.findByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(modelTrigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Model/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
||||
await waitFor(() => {
|
||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium')
|
||||
})
|
||||
fireEvent.click(modelTrigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' }))
|
||||
await waitFor(() => {
|
||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max')
|
||||
})
|
||||
|
||||
// fx-alpha starts in the running state. Selecting above is intentionally
|
||||
// allowed for the next turn; stop the fixture's resident run before sending
|
||||
// the route-report prompt.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
|
||||
const composer = await screen.findByPlaceholderText('给智能体发消息')
|
||||
fireEvent.change(composer, { target: { value: 'report model' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
|
||||
|
||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
||||
})
|
||||
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Web e2e scenario: the real host filters skill.list to the model-and-user
|
||||
// intersection before the browser slash source renders candidates. A real
|
||||
// chromium connects a fresh workspace seeded with all four policy quadrants;
|
||||
// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
interface SeedSkill {
|
||||
name: string
|
||||
description: string
|
||||
frontmatter: string
|
||||
}
|
||||
|
||||
const SKILLS: readonly SeedSkill[] = [
|
||||
{
|
||||
name: 'policy-shared',
|
||||
description: 'Available to both model and user invocation',
|
||||
frontmatter: '',
|
||||
},
|
||||
{
|
||||
name: 'policy-model-only',
|
||||
description: 'Available only to model invocation',
|
||||
frontmatter: 'user-invocable: false\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-user-only',
|
||||
description: 'Available only to user invocation',
|
||||
frontmatter: 'disable-model-invocation: true\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-trusted-only',
|
||||
description: 'Available only to trusted internal callers',
|
||||
frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
|
||||
},
|
||||
]
|
||||
|
||||
async function seedSkills(workspaceCwd: string): Promise<void> {
|
||||
for (const skill of SKILLS) {
|
||||
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
|
||||
await writeFile(join(directory, 'SKILL.md'), [
|
||||
'---',
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
...policyLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${skill.name}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: skill invocation policy through the real host', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSkills(scaffold.workspaceCwd)
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders only the model-and-user intersection in slash candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/policy')
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await expect.poll(
|
||||
() => menu.getByRole('option', { name: /policy-shared/ }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled keyless snapshot of the slash/input/session convergence under the
|
||||
// agent-parity model: the New Session view state locks the composer until a
|
||||
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
|
||||
// the '/' menu serves the session's wire command catalog (sessions are always
|
||||
// agent-backed — no draft/materialized split), a leadingInput command claims,
|
||||
// submits over the wire, and notices its result, and the SAME composer
|
||||
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
|
||||
// flips blank and surfaces the session in lists. This is the user-visible
|
||||
// acceptance anchor — package mocks do not substitute for the assembled
|
||||
// application transcript.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
// jsdom has no scrollIntoView; the slash menu follows its highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
||||
function boot(search: string): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Type into the machine-driven composer and let the change echo back. */
|
||||
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
await waitFor(() => { expect(composer.value).toBe(value) })
|
||||
}
|
||||
|
||||
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
// View state: no session entity — the composer renders locked; only the
|
||||
// workspace picker is live.
|
||||
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(locked.disabled).toBe(true)
|
||||
|
||||
// Pick (create) a Workspace: connectWorkspace materializes the full
|
||||
// Session+Agent and the provider swaps in the live blank-session hero.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'nova' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(composer.disabled).toBe(false)
|
||||
|
||||
// '/' opens the menu with the session's wire command catalog (the session
|
||||
// is agent-backed from birth — the catalog is the single-address list).
|
||||
await typeComposer(composer, '/')
|
||||
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
|
||||
const menuText = visibleText(menu)
|
||||
|
||||
// Pick /echo (leadingInput): the claim token lands in the same textarea.
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
|
||||
await waitFor(() => { expect(composer.value).toBe('/echo ') })
|
||||
|
||||
// Type args and submit: the claim executes over the wire and notices its
|
||||
// result; the token is consumed and the draft returns to plain text.
|
||||
await typeComposer(composer, '/echo hello parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('hello parser', {}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(composer.value).toBe('') })
|
||||
|
||||
// Slash execution does not flip blank: the selected row remains New Session.
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
expect(within(tree).getByText('1 session')).toBeDefined()
|
||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
||||
|
||||
// First plain send through the SAME textarea: acceptance logs the user
|
||||
// message and converts the existing sidebar row out of blank.
|
||||
const before = composer
|
||||
await typeComposer(composer, 'build me a parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Let's start building")).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
const after = document.querySelector('textarea')
|
||||
|
||||
expect({
|
||||
menuHadEcho: menuText.includes('echo'),
|
||||
menuHadCompact: menuText.includes('compact'),
|
||||
composerSurvivedConversion: after === before,
|
||||
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerSurvivedConversion": true,
|
||||
"menuHadCompact": true,
|
||||
"menuHadEcho": true,
|
||||
"sessionListed": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
[
|
||||
{
|
||||
"label": "fixture4 sessions"
|
||||
},
|
||||
{
|
||||
"label": "New Sessionnow"
|
||||
},
|
||||
{
|
||||
"label": "分叉 实验记录now"
|
||||
},
|
||||
{
|
||||
"label": "fixture2min"
|
||||
}
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"initial": {
|
||||
"sidebar": "Fixture 历史会话",
|
||||
"breadcrumb": "Fixture 历史会话",
|
||||
"documentTitle": "Fixture 历史会话 — DeepSeek Harness"
|
||||
},
|
||||
"revised": {
|
||||
"sidebar": "Fixture 修订标题",
|
||||
"breadcrumb": "Fixture 修订标题",
|
||||
"documentTitle": "Fixture 修订标题 — DeepSeek Harness"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: 技能
|
||||
- option "policy-shared Available to both model and user invocation" [selected]
|
||||
@@ -1,306 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session and pins the `card: 'terminal'` render
|
||||
// intent at both of its conversation render sites, for both chat-row shapes:
|
||||
// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
|
||||
// turn 65's `bash` on the keyed BashRow registration (resident body). Turn 65
|
||||
// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
|
||||
// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
|
||||
// pill; turn 60 carries the multi-line command's per-line prompt rows.
|
||||
//
|
||||
// The details panel's Output section is NOT covered here: tool rows stopped
|
||||
// being details-panel click targets, and nothing else in the assembled
|
||||
// application opens that panel, so the surface cannot be driven end to end.
|
||||
// Its terminal rendering stays pinned in ui-conversation's
|
||||
// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
|
||||
// directly.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against the populated fixture branch. */
|
||||
function boot(): void {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one terminal card's user-visible state. Output lines keep their interior
|
||||
* whitespace: holding column alignment is what this card exists for, so
|
||||
* collapsing runs of spaces would hide the behavior under test.
|
||||
*/
|
||||
function readCard(card: Element) {
|
||||
const status = card.querySelector('[class*="_status_"]')
|
||||
const expander = card.querySelector('button[aria-expanded]')
|
||||
return {
|
||||
// One entry per command line: a multi-line command is one row per line.
|
||||
prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
||||
`${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
|
||||
// Dots per prompt row: exactly one, on the first row — the exit status the
|
||||
// view carries is the whole call's, so a dot per line would assert a
|
||||
// per-line outcome bash does not report.
|
||||
dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
||||
row.querySelectorAll('[data-state]').length),
|
||||
status: status === null ? null : status.textContent,
|
||||
copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
|
||||
lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
|
||||
expander: expander === null ? null : {
|
||||
label: expander.getAttribute('aria-label'),
|
||||
text: expander.textContent,
|
||||
expanded: expander.getAttribute('aria-expanded'),
|
||||
},
|
||||
// The run-state dot at the head of the prompt line, by its StateDot state.
|
||||
runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
|
||||
runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
||||
// Every color the ANSI parser emits resolves through a --dsw-* token, so
|
||||
// the card follows the theme instead of painting literal terminal rgb.
|
||||
// Scoped to the output lines: the run-state dot is an inline-styled span
|
||||
// too, and its geometry is not an ANSI-resolved color.
|
||||
colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')]
|
||||
.map(span => span.getAttribute('style')))],
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
// Anchor on the expandable Workspace group row: the title and the blank
|
||||
// session row can both read "fixture".
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
/** The keyed BashRow of fixture turn 65 (the one carrying the ANSI sample). */
|
||||
function keyedBashRow(): Element {
|
||||
// Anchored on the BashRow wrapper (summary row + resident card), not on the
|
||||
// summary row itself: the summary now shows the presenter's description (the
|
||||
// contract's above-card text), so the command lives only in the card below it.
|
||||
const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
|
||||
.map(node => node.parentElement)
|
||||
.find((node): node is HTMLElement => node !== null && visibleText(node).includes('pnpm run check'))
|
||||
if (row === undefined) throw new Error('keyed bash row for turn 65 missing')
|
||||
return row
|
||||
}
|
||||
|
||||
/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
|
||||
function fallbackBashRow(): Element {
|
||||
const row = document.querySelector('[data-tool="fx-bash"]')
|
||||
if (row === null) throw new Error('fx-bash fallback row missing')
|
||||
return row
|
||||
}
|
||||
|
||||
it('renders the keyed bash row with a resident terminal card', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const row = keyedBashRow()
|
||||
const card = row.parentElement?.querySelector('[data-terminal]')
|
||||
if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
|
||||
// The prompt shortens the nested cwd to its last segment, the exit pill comes
|
||||
// from the sample's authored exit status (its body deliberately carries no
|
||||
// `[exit code: N]` marker, since the real presenter consumes that one), ANSI
|
||||
// runs land on theme tokens, and the chat cap (8) collapses the middle into a
|
||||
// head/tail split with an expander between them.
|
||||
expect(readCard(card)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"colors": [
|
||||
"font-weight: 700;",
|
||||
"color: var(--dsw-alias-state-success-primary);",
|
||||
"color: var(--dsw-alias-state-error-primary);",
|
||||
],
|
||||
"copy": "复制",
|
||||
"dotsPerPromptRow": [
|
||||
1,
|
||||
],
|
||||
"expander": {
|
||||
"expanded": "false",
|
||||
"label": "展开其余 13 行输出",
|
||||
"text": "… 其余 13 行",
|
||||
},
|
||||
"lines": [
|
||||
"Running 4 checks",
|
||||
"✓ typecheck 1.82s",
|
||||
"✓ lint 0.94s",
|
||||
"✓ duplication 2.10s",
|
||||
"StateDot.tsx 100% 100% 100% -",
|
||||
"markdown/Markdown.tsx 100% 100% 100% -",
|
||||
"",
|
||||
"1 of 4 checks failed",
|
||||
],
|
||||
"prompt": [
|
||||
"nested pnpm run check",
|
||||
],
|
||||
"runState": "error",
|
||||
"runStateLabel": "失败",
|
||||
"status": "退出码 1",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('the fallback row reaches the same card through its expand control', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const row = fallbackBashRow()
|
||||
expect(row.querySelector('[data-terminal]')).toBeNull()
|
||||
const toggle = row.querySelector('button[aria-expanded]')
|
||||
if (toggle === null) throw new Error('fallback row expand control missing')
|
||||
fireEvent.click(toggle)
|
||||
const card = await waitFor(() => {
|
||||
const found = row.querySelector('[data-terminal]')
|
||||
if (found === null) throw new Error('terminal card missing after expanding the fallback row')
|
||||
return found
|
||||
})
|
||||
// Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
|
||||
expect(readCard(card)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"colors": [],
|
||||
"copy": "复制",
|
||||
"dotsPerPromptRow": [
|
||||
1,
|
||||
0,
|
||||
],
|
||||
"expander": null,
|
||||
"lines": [
|
||||
"total 2",
|
||||
"drwxr-xr-x fixture",
|
||||
"-rw-r--r-- demo.txt",
|
||||
],
|
||||
"prompt": [
|
||||
"fixture ls -la",
|
||||
"$ echo done",
|
||||
],
|
||||
"runState": "done",
|
||||
"runStateLabel": "已完成",
|
||||
"status": null,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
|
||||
if (card === null || card === undefined) throw new Error('resident terminal card missing')
|
||||
const expander = card.querySelector('button[aria-expanded]')
|
||||
if (expander === null) throw new Error('height-cap expander missing')
|
||||
const capped = card.querySelectorAll('[class*="_line_"]').length
|
||||
|
||||
fireEvent.click(expander)
|
||||
await waitFor(() => {
|
||||
expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
expect({
|
||||
cappedLines: capped,
|
||||
expandedLines: card.querySelectorAll('[class*="_line_"]').length,
|
||||
expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
|
||||
// The card sits outside the summary row's click target, so toggling it
|
||||
// left the details panel shut.
|
||||
detailsOpen: screen.queryByText('Input') !== null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"cappedLines": 8,
|
||||
"detailsOpen": false,
|
||||
"expandedLines": 21,
|
||||
"expanderLabel": "收起输出",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Todo display snapshot over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session and pins the todo_write turn's two
|
||||
// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
|
||||
// derived from the call args) and the TodoPanel plan strip riding the
|
||||
// 'conversation.input.dock' slot (fed by the host `todos` projection via
|
||||
// useProjection, seeded by the tail history page), including the collapse
|
||||
// interaction and the next-turn clearance of the standing plan.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against the populated fixture branch. */
|
||||
function boot(): void {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
// Anchor on the expandable Workspace group row: the title and the blank
|
||||
// session row can both read "fixture", and the session-count meta shifts
|
||||
// when a blank session joins the group.
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
const session = await within(tree).findByText('Fixture 历史会话')
|
||||
fireEvent.click(session)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const row = document.querySelector('[data-sample="todo-row"]')
|
||||
if (row === null) throw new Error('todo row missing')
|
||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
||||
|
||||
// Header spans are adjacent inline nodes; textContent joins "To-dos" +
|
||||
// "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
|
||||
expect({
|
||||
row: visibleText(row),
|
||||
rowState: row.getAttribute('data-state'),
|
||||
panelHeader: visibleText(panel.querySelector('button') ?? panel),
|
||||
panelItems: [...panel.querySelectorAll('li')].map(item => ({
|
||||
status: item.getAttribute('data-status'),
|
||||
text: visibleText(item),
|
||||
})),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"panelHeader": "To-dos1/3 tasks · 1 in progress",
|
||||
"panelItems": [],
|
||||
"row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
|
||||
"rowState": "ok",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('expands the default-collapsed plan strip and restores its folded state', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
||||
const header = panel.querySelector('button')
|
||||
if (header === null) throw new Error('todo panel header missing')
|
||||
|
||||
expect({
|
||||
collapsedHeader: visibleText(header),
|
||||
expanded: header.getAttribute('aria-expanded'),
|
||||
listGone: panel.querySelector('ul') === null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"collapsedHeader": "To-dos1/3 tasks · 1 in progress",
|
||||
"expanded": "false",
|
||||
"listGone": true,
|
||||
}
|
||||
`)
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(panel.querySelectorAll('li')).toHaveLength(3)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(panel.querySelector('ul')).toBeNull()
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('hides the plan strip when the next turn starts', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
|
||||
|
||||
const composer = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 })
|
||||
fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
expect({
|
||||
promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
|
||||
panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
|
||||
// Historical todo_write row stays in the flow; only the dock strip clears.
|
||||
rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"panelGone": true,
|
||||
"promptVisible": "下一轮清空计划",
|
||||
"rowStillPresent": true,
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -1,397 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled keyless snapshots of the New Session flow under the agent-parity
|
||||
// model: startup auto-connects the recent Workspace's blank session when one
|
||||
// exists; without any Workspace the composer is locked in the pure view
|
||||
// state until one is chosen. Picking one materializes the full Session+Agent
|
||||
// (reuse-or-create of the workspace's blank session), the first ACCEPTED
|
||||
// prompt flips blank and surfaces the session in lists, and failures leave
|
||||
// no client-side transaction state: a failed attach keeps the view state
|
||||
// locked, a rejected prompt keeps the session blank with the draft restored.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
// Dual-face host package: its browser half fills the directory-flow holes
|
||||
// (the same composition row apps/cli mounts for the node-side backend).
|
||||
{
|
||||
id: '@deepseek-ai/dsh-host-directory-picker-browse',
|
||||
dir: '../host/directory-picker-browse',
|
||||
url: '/plugins/directory-picker-browse.js',
|
||||
rev: 'fx',
|
||||
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
||||
function boot(search: string): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
|
||||
function workspaceChip(): HTMLElement {
|
||||
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(element => element.getAttribute('aria-haspopup') === 'menu')
|
||||
if (chip === undefined) throw new Error('Workspace chip missing')
|
||||
return chip
|
||||
}
|
||||
|
||||
/** The locked view-state composer (no session yet). */
|
||||
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
|
||||
return await screen.findByPlaceholderText(
|
||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** The live blank-session hero composer (session materialized). */
|
||||
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
|
||||
return await screen.findByPlaceholderText(
|
||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Edit the machine-owned controlled input and assert the same-tick echo. */
|
||||
function setComposerText(composer: HTMLElement, value: string): void {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
expect((composer as HTMLTextAreaElement).value).toBe(value)
|
||||
}
|
||||
|
||||
/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
|
||||
async function createWorkspaceViaPicker(name: string): Promise<void> {
|
||||
fireEvent.click(workspaceChip())
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: name },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
}
|
||||
|
||||
/** Pick an existing Workspace row from the chip menu. */
|
||||
async function pickWorkspace(title: string): Promise<void> {
|
||||
fireEvent.click(workspaceChip())
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: title }))
|
||||
}
|
||||
|
||||
it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await findLockedComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
|
||||
expect({
|
||||
headline: visibleText(screen.getByText("Let's start building")),
|
||||
chip: visibleText(workspaceChip()),
|
||||
composerDisabled: composer.disabled,
|
||||
sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
|
||||
sidebar: visibleText(tree),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "Choose workspace",
|
||||
"composerDisabled": true,
|
||||
"headline": "Let's start building",
|
||||
"sendDisabled": true,
|
||||
"sidebar": "No sessions yet",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await findLockedComposer()
|
||||
fireEvent.click(workspaceChip())
|
||||
const menu = await screen.findByRole('menu')
|
||||
// The composed flow package occupies the directory-flow hole, so the
|
||||
// picking affordance is present (no advertised-kind read exists anymore).
|
||||
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
|
||||
.toEqual(['Open local folder…', 'Create a new workspace'])
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
// The browse occupant renders the Select Workspace Directory dialog at the
|
||||
// fixture home; select Documents, advance into project, and adopt it.
|
||||
const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
|
||||
// Row targeting goes through the visible label text: listitem accessible-name
|
||||
// computation differs across dom-accessibility-api environments, while the
|
||||
// row's name span is stable (clicks bubble to the row button).
|
||||
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
|
||||
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
|
||||
// Open disables while the selection's child listing is in flight; wait for
|
||||
// the enabled state or the click lands on a dead button on slow runners.
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
|
||||
}, { timeout: 10_000 })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
|
||||
await findHeroComposer()
|
||||
await waitFor(() => {
|
||||
expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the recent Workspace and opens its blank Session on first load', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
expect({
|
||||
chip: visibleText(workspaceChip()),
|
||||
composerDisabled: composer.disabled,
|
||||
blankRow: within(tree).getByText('New Session').textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"blankRow": "New Session",
|
||||
"chip": "fixture",
|
||||
"composerDisabled": false,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('creating a Workspace materializes and lists its selected blank Session', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
|
||||
// The pick connected the workspace: full Session+Agent exists, composer live.
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('created Workspace projection missing')
|
||||
|
||||
expect({
|
||||
composerDisabled: composer.disabled,
|
||||
chip: visibleText(workspaceChip()),
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "nova",
|
||||
"composerDisabled": false,
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
// New Session resolves through the recent Workspace and reuses its blank
|
||||
// session in place: no locked interlude, no second entity.
|
||||
const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
|
||||
.find(button => visibleText(button) === 'New Session')
|
||||
if (newSessionButton === undefined) throw new Error('New Session button missing')
|
||||
fireEvent.click(newSessionButton)
|
||||
const composer = await findHeroComposer()
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
setComposerText(composer, 'first light')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
// Conversion: the accepted prompt flips blank without adding a second row.
|
||||
await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('converted Session projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
promptVisible: screen.getByText('first light', { exact: true }).textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"promptVisible": "first light",
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('a failed Workspace attach recovers by reusing the published blank session', async () => {
|
||||
boot('?fixture&fixtureAttach=fail')
|
||||
|
||||
// The rejected startup connect surfaces the locked view state first: the
|
||||
// failure leaves no client-side transaction state to unwind.
|
||||
await findLockedComposer()
|
||||
|
||||
// The host published the session before rejecting attachment (blank, with
|
||||
// the workspace cwd), so the next connect — retry or manual pick — reuses
|
||||
// it instead of minting a duplicate, and the hero opens on it.
|
||||
await pickWorkspace('fixture')
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('fixture Workspace projection missing')
|
||||
|
||||
expect({
|
||||
headline: visibleText(screen.getByText("Let's start building")),
|
||||
composerDisabled: composer.disabled,
|
||||
chip: visibleText(workspaceChip()),
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "fixture",
|
||||
"composerDisabled": false,
|
||||
"headline": "Let's start building",
|
||||
"workspace": "fixture3 sessions",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
|
||||
boot('?fixture=empty&fixturePrompt=reject')
|
||||
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
const composer = await findHeroComposer()
|
||||
|
||||
setComposerText(composer, 'do not lose this')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
|
||||
// Failure restore rides the machine (no pendingPrompt transaction): the
|
||||
// draft returns to the same resident textarea one render later. The
|
||||
// attempt flips the composer out of the hero (engaging = retry chrome),
|
||||
// but acceptance never happened: the session row stays New Session.
|
||||
const retained = await screen.findByDisplayValue('do not lose this')
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('rejected-send Workspace projection missing')
|
||||
|
||||
expect({
|
||||
error: visibleText(alert),
|
||||
prompt: (retained as HTMLTextAreaElement).value,
|
||||
blankRow: within(tree).getByText('New Session').textContent,
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"blankRow": "New Session",
|
||||
"error": "fixture: prompt rejected before acceptance (agent-busy)",
|
||||
"prompt": "do not lose this",
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('switching Workspace before the first message carries the draft to the new blank session', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
const composer = await findHeroComposer()
|
||||
setComposerText(composer, 'carry me')
|
||||
|
||||
// Switch = session switch: the new workspace's blank session takes over,
|
||||
// the typed draft moves machine-to-machine, the old blank stays hidden.
|
||||
await createWorkspaceViaPicker('nova')
|
||||
await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
|
||||
const carried = await screen.findByDisplayValue('carry me')
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
|
||||
|
||||
expect({
|
||||
chip: visibleText(workspaceChip()),
|
||||
prompt: (carried as HTMLTextAreaElement).value,
|
||||
fixtureWorkspace: visibleText(fixtureGroup),
|
||||
novaWorkspace: visibleText(novaGroup),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "nova",
|
||||
"fixtureWorkspace": "fixture3 sessions",
|
||||
"novaWorkspace": "nova1 session",
|
||||
"prompt": "carry me",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -36,7 +36,8 @@
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts"
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -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 docs/architecture.md
|
||||
architecture.md: bb5414d6bb108056bf2ff25366e5afe261e1803a
|
||||
architecture.zh.md: 6d39a320019a1bf87141be0874a5a20a51fc3fbb
|
||||
architecture.md: 1fe5c1dfa4aee8c3bfe5ac634f47bb68f36afe9f
|
||||
architecture.zh.md: d754c6a2ea5bcd38524d31d02bc4f38ca2074942
|
||||
|
||||
@@ -47,6 +47,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
|
||||
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
|
||||
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider |
|
||||
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
|
||||
| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas |
|
||||
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
|
||||
|
||||
## Event
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
|
||||
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 |
|
||||
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) |
|
||||
| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 |
|
||||
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -29,6 +29,9 @@ flowchart LR
|
||||
pkg_invariants["invariants"]
|
||||
svc_invariants["ctx.invariants<br/>Package-owned invariant registry"]
|
||||
pkg_scope["scope"]
|
||||
pkg_typert_registry["typert-registry"]
|
||||
svc_typert["ctx.typert<br/>Runtime type registry"]
|
||||
pkg_typert_loader["typert-loader"]
|
||||
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
@@ -224,6 +227,7 @@ flowchart LR
|
||||
pkg_tools --> svc_tools
|
||||
pkg_tui --> svc_tui
|
||||
pkg_tui --> svc_userInteraction
|
||||
pkg_typert_registry --> svc_typert
|
||||
pkg_user_interaction --> svc_userInteraction
|
||||
pkg_web --> svc_web
|
||||
pkg_web_fetch_local --> svc_web
|
||||
@@ -318,6 +322,7 @@ flowchart LR
|
||||
svc_tools --> pkg_tool_subagent
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_typert --> pkg_typert_loader
|
||||
svc_userInteraction --> pkg_tool_ask_user
|
||||
svc_userInteraction --> pkg_tui
|
||||
svc_web --> pkg_tool_web
|
||||
@@ -334,6 +339,7 @@ flowchart LR
|
||||
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
|
||||
| `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), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | 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.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. |
|
||||
| `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), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
|
||||
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
|
||||
|
||||
@@ -1241,7 +1241,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts)
|
||||
Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-skill-local`
|
||||
|
||||
@@ -1273,7 +1273,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill-local/src/index.ts:48`](../packages/skill/skill-local/src/index.ts)
|
||||
Source: [`packages/skill/skill-local/src/index.ts:49`](../packages/skill/skill-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-spill-local`
|
||||
|
||||
@@ -1748,7 +1748,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/skill/tool-skill/src/index.ts:25`](../packages/skill/tool-skill/src/index.ts)
|
||||
Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-subagent`
|
||||
|
||||
@@ -2022,6 +2022,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) ·
|
||||
|
||||
Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-typert-loader`
|
||||
|
||||
Requires: `typert` · `loader`
|
||||
|
||||
```ts config-catalog
|
||||
/** Additional package artifacts whose owning plugins are nested behind another Loader entry. */
|
||||
export interface Config {
|
||||
/** Exact npm package names that must resolve and export `./typert`. */
|
||||
packages?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/typert/loader/src/index.ts:47`](../packages/typert/loader/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
```ts config-catalog
|
||||
@@ -2264,6 +2278,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
|
||||
|
||||
@@ -2315,3 +2330,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
|
||||
|
||||
@@ -1,6 +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
|
||||
adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43
|
||||
adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053
|
||||
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md
|
||||
adding-a-package.md: 2dd9165c4b5a7e04ecc7af0507f364fe89b294bb
|
||||
adding-a-package.zh.md: 79f022531de500eed1d53b0915ee933b047121ff
|
||||
|
||||
@@ -37,7 +37,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example
|
||||
|
||||
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
|
||||
|
||||
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`.
|
||||
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`.
|
||||
|
||||
## 3. Decide the package topology
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c
|
||||
|
||||
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
|
||||
|
||||
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。
|
||||
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`、`scripts/check-workspace-constraints.ts`。
|
||||
|
||||
## 3. 确定包拓扑
|
||||
|
||||
|
||||
@@ -1,6 +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
|
||||
adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2
|
||||
adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801
|
||||
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md
|
||||
adding-a-vendored-package.md: a951a96f62d2ea3aa693a24d83bf46a1a12070cd
|
||||
adding-a-vendored-package.zh.md: 878adbb203f8c79db0f127cb1ac58cd9e7a09171
|
||||
|
||||
@@ -42,7 +42,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts`
|
||||
| `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications |
|
||||
| `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) |
|
||||
|
||||
Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`.
|
||||
Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`.
|
||||
|
||||
## 3. Mind the manifest guard
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显
|
||||
| `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 |
|
||||
| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) |
|
||||
|
||||
以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。
|
||||
以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。
|
||||
|
||||
## 3. 注意 manifest 守卫
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
|
||||
|
||||
The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).
|
||||
|
||||
Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).
|
||||
Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).
|
||||
|
||||
## `agent/*`
|
||||
|
||||
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/created` — emit
|
||||
|
||||
@@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/dequeue` — emit
|
||||
|
||||
@@ -119,7 +119,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary,
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/discard` — emit
|
||||
|
||||
@@ -142,7 +142,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/enqueue` — emit
|
||||
|
||||
@@ -164,7 +164,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -187,7 +187,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
@@ -211,7 +211,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request-error` — waterfall
|
||||
|
||||
@@ -241,7 +241,7 @@ Handle a model-request failure after its failed step has closed but before the f
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:361`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -263,7 +263,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/settled` — emit
|
||||
|
||||
@@ -288,7 +288,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:390`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -308,7 +308,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step` — serial
|
||||
|
||||
@@ -332,7 +332,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stopping` — serial
|
||||
|
||||
@@ -358,7 +358,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `agent-loop/*`
|
||||
|
||||
@@ -659,76 +659,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan
|
||||
'skills/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
## `slash/*`
|
||||
|
||||
### `slash/input-begin-command` — bail
|
||||
|
||||
Applies one command claim to the scoped Input. Dispatched with the session's scope carrier; the owning session's input listener returns `true` only after the phase and span CAS checks pass and the machine actually mutated — producers treat anything else as "not applied".
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Applies one command claim to the scoped Input. Dispatched with the
|
||||
* session's scope carrier; the owning session's input listener returns
|
||||
* `true` only after the phase and span CAS checks pass and the machine
|
||||
* actually mutated — producers treat anything else as "not applied".
|
||||
* @param request - Claim and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:232`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-consume-token` — bail
|
||||
|
||||
Consumes one command token after business success (popup settle / menu-pick execute). Same carrier routing and applied-truth contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Consumes one command token after business success (popup settle /
|
||||
* menu-pick execute). Same carrier routing and applied-truth contract.
|
||||
* @param request - Exact span or bare-token guard.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:246`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-reference` — bail
|
||||
|
||||
Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Inserts one reference into the scoped Input (same carrier routing and
|
||||
* applied-truth contract as begin-command).
|
||||
* @param request - Reference and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:239`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-text` — bail
|
||||
|
||||
Replaces the trigger token span with literal text — the plain-text reference path (decision 21). Same carrier routing and applied-truth contract; the draft gains ordinary characters, no occurrence entry.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Replaces the trigger token span with literal text — the plain-text
|
||||
* reference path (decision 21). Same carrier routing and applied-truth
|
||||
* contract; the draft gains ordinary characters, no occurrence entry.
|
||||
* @param request - Replacement text and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:254`](../../packages/client/ui-slash/src/types.ts)
|
||||
Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
|
||||
@@ -978,7 +978,7 @@ signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResu
|
||||
* @param reason - diagnostic cleanup reason.
|
||||
* @returns true for a newly closed session, false when the same close is already in flight.
|
||||
*/
|
||||
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean>
|
||||
async kill(owner: Agent, id: PtySessionId, reason: string = 'model request'): Promise<boolean>
|
||||
|
||||
/**
|
||||
* List fresh snapshots for exactly one owner.
|
||||
@@ -1447,7 +1447,7 @@ Exact-read consumer that prepares immutable cross-session message context.
|
||||
* @param signal - optional cancellation boundary for host autocomplete teardown.
|
||||
* @returns candidates labeled by latest title or, when absent, session id.
|
||||
*/
|
||||
async listCandidates( agent: Agent, query = '', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>
|
||||
async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>
|
||||
|
||||
/**
|
||||
* Snapshot all references before enqueue and return one aggregated durable context.
|
||||
@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
@@ -1641,7 +1641,7 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand.
|
||||
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -1658,22 +1658,23 @@ registerProvider(create: (control: SkillProviderControl) => SkillProvider): () =
|
||||
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
|
||||
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
|
||||
* receives a no-op disposer so it cannot remove the winner.
|
||||
* @param skill - the complete skill definition to expose for discovery.
|
||||
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
|
||||
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
|
||||
*/
|
||||
register(skill: SkillRegistration): () => void
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace. Lookup options and
|
||||
* provider candidates are readonly same-process values borrowed throughout
|
||||
* discovery.
|
||||
* List invocation-neutral skill summaries for a workspace. Consumers apply
|
||||
* model or user invocation policy at their operational boundary. Lookup
|
||||
* options and provider candidates are readonly same-process values borrowed
|
||||
* throughout discovery.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
* @returns all sorted winning summaries.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
||||
|
||||
/**
|
||||
* Observe the current model-invocable catalog and whether discovery completed within a stable revision.
|
||||
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
|
||||
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
||||
* retry on their next request boundary.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
@@ -1694,7 +1695,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
|
||||
|
||||
Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillProviderControl](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md)
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:178`](../../packages/skill/skill/src/index.ts)
|
||||
Source: [`packages/skill/skill/src/index.ts:209`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
## `ctx.spillStore` — `SpillStore` (abstract seam)
|
||||
|
||||
@@ -2198,6 +2199,67 @@ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.typert` — `TypertRegistry`
|
||||
|
||||
Registry of generated schemas and package reflection.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities or schema keys reject the whole batch.
|
||||
* @param contribution - generated schemas and package metadata.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): () => void
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypertSchemaRecord | undefined
|
||||
|
||||
/**
|
||||
* Resolve one required schema.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record.
|
||||
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
|
||||
*/
|
||||
resolve(key: string): TypertSchemaRecord
|
||||
|
||||
/**
|
||||
* Enumerate live schemas in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching schema records.
|
||||
*/
|
||||
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]
|
||||
|
||||
/**
|
||||
* Look up generated reflection for one package face.
|
||||
* @param packageName - exact npm package name.
|
||||
* @param face - face to query; defaults to the host runtime.
|
||||
* @returns the live package record, or `undefined` when absent.
|
||||
*/
|
||||
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined
|
||||
|
||||
/**
|
||||
* Enumerate generated package reflection in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching package records.
|
||||
*/
|
||||
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]
|
||||
|
||||
/**
|
||||
* Project a live Zod schema to JSON Schema without caching the result.
|
||||
* @param key - global schema key.
|
||||
* @param params - Zod projection parameters.
|
||||
* @returns a fresh JSON Schema document.
|
||||
*/
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
|
||||
```
|
||||
|
||||
Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
|
||||
|
||||
@@ -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 docs/core-data-structures/core.md
|
||||
core.md: b9df539136c2661537775ba9a425bdf7ef1fd958
|
||||
core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5
|
||||
core.md: 0ab58864bf70a52554d0c4b9da10fa3fc49e9dc2
|
||||
core.zh.md: 5719c603d0d7576e9fc030e73fb9a6857fef458c
|
||||
|
||||
@@ -477,7 +477,10 @@ type AgentCancelCause =
|
||||
`Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix.
|
||||
|
||||
```ts type-equiv
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
/**
|
||||
* Public live-agent handle with aliases over the unified delivery primitive.
|
||||
* @typert object
|
||||
*/
|
||||
interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
|
||||
@@ -485,7 +485,10 @@ type AgentCancelCause =
|
||||
`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。
|
||||
|
||||
```ts type-equiv
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
/**
|
||||
* Public live-agent handle with aliases over the unified delivery primitive.
|
||||
* @typert object
|
||||
*/
|
||||
interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
|
||||
@@ -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 docs/core-data-structures/session.md
|
||||
session.md: 6ae0ab79b5c7bc3bc1859bf819ce25679672a7f0
|
||||
session.zh.md: 79ed40f7eee7a8cae05a366d646f85580c73d5d2
|
||||
session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6
|
||||
session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223
|
||||
|
||||
@@ -302,6 +302,7 @@ The body-stripped declaration keeps the plain class's public constructor, state
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
declare class Session {
|
||||
/** The ordered surface over this session's event log. */
|
||||
|
||||
@@ -304,6 +304,7 @@ interface SurfaceFoldResult {
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
declare class Session {
|
||||
/** The ordered surface over this session's event log. */
|
||||
|
||||
@@ -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 docs/core-data-structures/skills.md
|
||||
skills.md: a52a21d2bfe4c4e4529953fdf09998a9a281cefd
|
||||
skills.zh.md: 6ebc167f7e5dc436ac1f79b8747940e4beb30222
|
||||
skills.md: d4b41845bea009444653739abad712e9ce3afb13
|
||||
skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974
|
||||
|
||||
@@ -87,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
|
||||
|
||||
## Summaries, candidates, and complete definitions
|
||||
|
||||
`SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name.
|
||||
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model.
|
||||
|
||||
```ts type-equiv
|
||||
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
|
||||
/** Invocation controls shared by skill discovery consumers. */
|
||||
interface SkillInvocationPolicy {
|
||||
/** Whether model-facing catalogs and loaders include this skill. */
|
||||
readonly modelInvocable: boolean
|
||||
/** Whether human-facing command catalogs and loaders include this skill. */
|
||||
readonly userInvocable: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
|
||||
interface SkillSummary {
|
||||
/** Kebab-case identifier used with the `skill` tool. */
|
||||
/** Kebab-case identifier used to address the skill. */
|
||||
readonly name: string
|
||||
/** Short routing description shown to the model. */
|
||||
/** Short routing description shown by discovery consumers. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance shown to the model. */
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
readonly disableModelInvocation?: boolean
|
||||
/** Resolved model and user invocation controls. */
|
||||
readonly invocation: SkillInvocationPolicy
|
||||
/** Discovery source that produced this winning skill. */
|
||||
readonly source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
@@ -109,12 +119,14 @@ interface SkillSummary {
|
||||
}
|
||||
```
|
||||
|
||||
`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing a consumer to retain its last-good model catalog and retry.
|
||||
`ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding required field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, defaults omitted fields to `true`, and projects every parsed skill into this normalized policy.
|
||||
|
||||
`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted invocation-neutral summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing each consumer to retain its last-good filtered catalog and retry.
|
||||
|
||||
```ts type-equiv
|
||||
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||
interface SkillCatalogSnapshot {
|
||||
/** Sorted model-invocable summaries collected in this observation. */
|
||||
/** Sorted invocation-neutral summaries collected in this observation. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed without a concurrent catalog revision. */
|
||||
readonly complete: boolean
|
||||
@@ -159,11 +171,16 @@ interface SkillDefinition extends SkillSummary {
|
||||
}
|
||||
```
|
||||
|
||||
Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches.
|
||||
Runtime skill inputs may omit invocation controls and the provider label. The registry resolves both defaults once, then uses the same complete definition shape and first-wins collection order as providers. The returned disposer removes the contribution and invalidates discovery caches.
|
||||
|
||||
```ts type-equiv
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
|
||||
type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
|
||||
/** Invocation controls; omission permits both model and user surfaces. */
|
||||
readonly invocation?: SkillInvocationPolicy
|
||||
/** Provider label; omission uses the registry-owned runtime provider. */
|
||||
readonly provider?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Lookup and configuration
|
||||
@@ -198,4 +215,4 @@ interface Config {
|
||||
|
||||
Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `<available_skills>` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State.
|
||||
|
||||
The model-facing `skill({ name })` tool validates the kebab-case name, rereads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results.
|
||||
The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results.
|
||||
|
||||
@@ -87,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
|
||||
|
||||
## 摘要、候选项与完整定义
|
||||
|
||||
`SkillSummary` 是注册表中可供模型调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用 body 或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。
|
||||
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。
|
||||
|
||||
```ts type-equiv
|
||||
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
|
||||
/** Invocation controls shared by skill discovery consumers. */
|
||||
interface SkillInvocationPolicy {
|
||||
/** Whether model-facing catalogs and loaders include this skill. */
|
||||
readonly modelInvocable: boolean
|
||||
/** Whether human-facing command catalogs and loaders include this skill. */
|
||||
readonly userInvocable: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
|
||||
interface SkillSummary {
|
||||
/** Kebab-case identifier used with the `skill` tool. */
|
||||
/** Kebab-case identifier used to address the skill. */
|
||||
readonly name: string
|
||||
/** Short routing description shown to the model. */
|
||||
/** Short routing description shown by discovery consumers. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance shown to the model. */
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
readonly disableModelInvocation?: boolean
|
||||
/** Resolved model and user invocation controls. */
|
||||
readonly invocation: SkillInvocationPolicy
|
||||
/** Discovery source that produced this winning skill. */
|
||||
readonly source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
@@ -109,12 +119,14 @@ interface SkillSummary {
|
||||
}
|
||||
```
|
||||
|
||||
`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集并排序的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此消费方可以保留上一份可用模型目录并重试。
|
||||
`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。
|
||||
|
||||
`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集、排序且与调用策略无关的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此每个消费方可以保留上一份经过自身过滤的可用目录并重试。
|
||||
|
||||
```ts type-equiv
|
||||
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||
interface SkillCatalogSnapshot {
|
||||
/** Sorted model-invocable summaries collected in this observation. */
|
||||
/** Sorted invocation-neutral summaries collected in this observation. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed without a concurrent catalog revision. */
|
||||
readonly complete: boolean
|
||||
@@ -159,11 +171,16 @@ interface SkillDefinition extends SkillSummary {
|
||||
}
|
||||
```
|
||||
|
||||
运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
|
||||
运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
|
||||
|
||||
```ts type-equiv
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
|
||||
type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
|
||||
/** Invocation controls; omission permits both model and user surfaces. */
|
||||
readonly invocation?: SkillInvocationPolicy
|
||||
/** Provider label; omission uses the registry-owned runtime provider. */
|
||||
readonly provider?: string
|
||||
}
|
||||
```
|
||||
|
||||
## 查找与配置
|
||||
@@ -198,4 +215,4 @@ interface Config {
|
||||
|
||||
在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `<available_skills>` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。
|
||||
|
||||
面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 重新读取完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。
|
||||
面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它为调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将未解析的 skill 报告为 unknown 或 no longer available,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。
|
||||
|
||||
@@ -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 docs/development.md
|
||||
development.md: 0a18e29d3da4f694707521e230017e6b22cad740
|
||||
development.zh.md: 885b51c701267215cc50d31ecd1694ae2c9af9ca
|
||||
development.md: 859de959dc5d93c2f0ddbe5c7f700d4bdaf9e09b
|
||||
development.zh.md: faa6a07731deed77663727b3ee1f0fe060580c53
|
||||
|
||||
@@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
@@ -106,8 +106,8 @@ pnpm run test:coverage # unit tests with per-file coverage gates
|
||||
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
|
||||
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
|
||||
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
|
||||
pnpm run lint # eslint .
|
||||
pnpm run lint:fix # eslint . --fix
|
||||
pnpm run lint # oxlint .
|
||||
pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
|
||||
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
|
||||
|
||||
@@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
@@ -106,8 +106,8 @@ pnpm run test:coverage # unit tests with per-file coverage gates
|
||||
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
|
||||
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
|
||||
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
|
||||
pnpm run lint # eslint .
|
||||
pnpm run lint:fix # eslint . --fix
|
||||
pnpm run lint # oxlint .
|
||||
pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
|
||||
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
|
||||
|
||||
@@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:339`](../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:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:342`](../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:361`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:390`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
|
||||
@@ -35,11 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`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:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:157`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:232`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:246`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:239`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:254`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -67,9 +63,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `ui-command` |
|
||||
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`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/plugin` | - | `hmr`, `modules`, `webserver` |
|
||||
| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent) |
|
||||
| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
|
||||
| `slash/input-begin-command` | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | - | `ui-conversation` |
|
||||
| `slash/input-insert-text` | - | `ui-conversation` |
|
||||
| `slots/changed` | `runtime` (`emit`) | - |
|
||||
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
|
||||
|
||||
|
||||
@@ -241,6 +241,11 @@ flowchart TD
|
||||
pkg_session_telemetry["session-telemetry"]
|
||||
pkg_session_telemetry_otel["session-telemetry-otel"]
|
||||
end
|
||||
subgraph group_typert["packages/typert"]
|
||||
pkg_typert_generator["typert-generator"]
|
||||
pkg_typert_loader["typert-loader"]
|
||||
pkg_typert_registry["typert-registry"]
|
||||
end
|
||||
subgraph group_workflow["packages/workflow"]
|
||||
pkg_tool_ralph["tool-ralph"]
|
||||
pkg_tool_workflow["tool-workflow"]
|
||||
@@ -263,7 +268,6 @@ flowchart TD
|
||||
pkg_client_modules --> pkg_invariants
|
||||
pkg_client_runtime --> pkg_invariants
|
||||
pkg_client_ui_primitives --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_slots --> pkg_invariants
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
@@ -274,6 +278,8 @@ flowchart TD
|
||||
pkg_host_webserver --> pkg_invariants
|
||||
pkg_storage --> pkg_invariants
|
||||
pkg_subprocess --> pkg_invariants
|
||||
pkg_typert_generator --> pkg_invariants
|
||||
pkg_typert_registry --> pkg_invariants
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_llm --> pkg_invariants
|
||||
pkg_llm --> pkg_timeout
|
||||
@@ -297,10 +303,6 @@ flowchart TD
|
||||
pkg_client_ui_settings --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
@@ -321,6 +323,8 @@ flowchart TD
|
||||
pkg_storage_sqlite --> pkg_storage
|
||||
pkg_subprocess_local --> pkg_invariants
|
||||
pkg_subprocess_local --> pkg_subprocess
|
||||
pkg_typert_loader --> pkg_invariants
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_llm_deepseek --> pkg_invariants
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
pkg_llm_deepseek --> pkg_timeout
|
||||
@@ -336,12 +340,19 @@ flowchart TD
|
||||
pkg_system_prompt --> pkg_scope
|
||||
pkg_web --> pkg_invariants
|
||||
pkg_web --> pkg_llm
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
pkg_client_ui_settings_general --> pkg_client_runtime
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings_general --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_locale
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_slash --> pkg_client_locale
|
||||
pkg_client_ui_slash --> pkg_client_runtime
|
||||
pkg_client_ui_slash --> pkg_client_ui_primitives
|
||||
@@ -609,6 +620,7 @@ flowchart TD
|
||||
pkg_client_ui_goal --> pkg_goal
|
||||
pkg_client_ui_goal --> pkg_invariants
|
||||
pkg_client_ui_model --> pkg_client_connection
|
||||
pkg_client_ui_model --> pkg_client_locale
|
||||
pkg_client_ui_model --> pkg_client_runtime
|
||||
pkg_client_ui_model --> pkg_client_ui_command
|
||||
pkg_client_ui_model --> pkg_client_ui_conversation
|
||||
@@ -992,7 +1004,6 @@ flowchart TD
|
||||
| [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -1003,6 +1014,8 @@ flowchart TD
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
|
||||
| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
|
||||
| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1010,7 +1023,6 @@ flowchart TD
|
||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
@@ -1019,12 +1031,15 @@ flowchart TD
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`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` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1089,7 +1104,7 @@ flowchart TD
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
|
||||
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
|
||||
6
docs/typert-catalog-integration-design.i18n.yaml
Normal file
6
docs/typert-catalog-integration-design.i18n.yaml
Normal file
@@ -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 docs/typert-catalog-integration-design.md
|
||||
typert-catalog-integration-design.md: c7d601730655f61f3b875ad5ad6997d3c888bfea
|
||||
typert-catalog-integration-design.zh.md: abaddfe1f740d4bd7cff5b2db8fd91e34626aab8
|
||||
133
docs/typert-catalog-integration-design.md
Normal file
133
docs/typert-catalog-integration-design.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Typert Catalog Integration Design
|
||||
|
||||
English | [中文](typert-catalog-integration-design.zh.md)
|
||||
|
||||
## Current State and Problem
|
||||
|
||||
Typert already provides separate host/client `FaceModel` instances, a `TypeGraph` with explicit cross-face references, and analysis support for services, events, `@typert object`, generics, inheritance, and External types. The TypeScript compiler API should only translate source code into this standard model; downstream consumers should not traverse the TypeScript AST again.
|
||||
|
||||
The repository currently has two catalog pipelines that analyze TypeScript source directly: the static API catalog consumed by `tool-cordis`, and the generation and freshness gate for `docs/cordis-catalog/events.md` and `docs/cordis-catalog/services.md`. They analyze the same services, events, and related types, but maintain separate collection and rendering logic, so they cannot prove that the Typert model is sufficient to represent the existing domain semantics.
|
||||
|
||||
The first phase makes both pipelines consume the Typert model while keeping the three committed artifacts character-for-character identical to their pre-migration versions:
|
||||
|
||||
- `docs/cordis-catalog/events.md`
|
||||
- `docs/cordis-catalog/services.md`
|
||||
- `packages/cordis/tool-cordis/src/api-catalog.ts`
|
||||
|
||||
This phase does not require product plugins to publish Typert subpaths, example applications to load Typert, or changes to the runtime dependencies of `tool-cordis`.
|
||||
|
||||
## Options
|
||||
|
||||
### Drive `tool-cordis` from the Runtime Registry
|
||||
|
||||
Each plugin publishes and loads Typert artifacts, then `tool-cordis` reads the current runtime model from `ctx.typert`. This path reflects the set of plugins actually loaded, but it requires every product package represented in the catalog to add package exports, generated artifacts, registry contributions, and application assembly. That integration surface is much larger than the analysis capability being validated now.
|
||||
|
||||
### Publish Typert Artifacts Repository-Wide, Then Aggregate Them Statically
|
||||
|
||||
All product packages generate host/client JS and DTS during the normal build/typecheck process, then the catalog generator aggregates those artifacts. This path establishes the complete publication protocol up front, but it also changes many package manifests and the build topology at once, coupling catalog migration to repository-wide Typert publication.
|
||||
|
||||
### Analyze at Build Time, Then Project the Catalog
|
||||
|
||||
`WorkspaceAnalyzer` builds a `WorkspaceModel` and `TypeGraph` from the host TypeScript project. The repository-specific `CordisCatalogProjector` consumes only that model and generates the three texts. `tool-cordis` continues to import the committed static `api-catalog.ts`, so the runtime does not need the Typert service.
|
||||
|
||||
This phase uses build-time projection. It directly verifies that the standard Typert model can replace the existing AST collector while leaving runtime publication and automatic loading to separate follow-up decisions.
|
||||
|
||||
## Phase-One Architecture
|
||||
|
||||
```text
|
||||
tsconfig.host.json
|
||||
│
|
||||
▼
|
||||
WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界
|
||||
│
|
||||
▼
|
||||
WorkspaceModel + TypeGraph
|
||||
│
|
||||
▼
|
||||
CordisCatalogProjector ── 不依赖 TypeScript AST
|
||||
├── docs/cordis-catalog/events.md
|
||||
├── docs/cordis-catalog/services.md
|
||||
└── packages/cordis/tool-cordis/src/api-catalog.ts
|
||||
```
|
||||
|
||||
The objects have the following responsibilities:
|
||||
|
||||
- `WorkspaceAnalyzer` analyzes packages, exports, services, events, type declarations, and reference relationships, and produces a compiler-independent model.
|
||||
- `WorkspaceModel` and `TypeGraph` are the standard data structures shared by all generation and scanning analyses. They preserve developer-authored generics, inheritance, and type trees without retaining the TypeScript AST.
|
||||
- The root entry point of `@deepseek-ai/dsh-typert-generator` exports `CordisCatalogProjector`, which performs model-driven selection, sorting, summary extraction, source location handling, JSDoc completeness checks, type-link closure, and rendering in three text formats. Its implementation remains in a dedicated Cordis catalog file, but it does not create another package subpath or embed a list of repository type names.
|
||||
- `scripts/gen-cordis-catalog.ts` provides `LINK_MAP`, `FOUNDATION_TYPE_NAMES`, `TYPE_LINK_EXEMPTIONS`, and the inherited Cordis list, injects them explicitly into the projector through `CordisCatalogPolicy`, and owns the write/check CLI behavior. The vendor Cordis core pages continue to be generated by a separate pinned-source projector.
|
||||
- `tool-cordis` imports only the static `api-catalog.ts` and does not depend on `typert-registry` or `typert-loader`.
|
||||
|
||||
`CordisCatalogProjector` is a repository-specific downstream consumer and is not part of Typert's general-purpose model. When adding another category, first extend the standard model, then add the corresponding projector. The Typert analyzer must not absorb Cordis documentation formats or `tool-cordis` presentation logic.
|
||||
|
||||
## Model Additions
|
||||
|
||||
In addition to type structure, the catalog's character-for-character projection needs the declaration forms written by developers and exact source locations. The standard model therefore retains event/service locations, body-free text for events and members, parameter initializers, and the export status and canonical text of type declarations. `SourceDeclarationModel` also indexes top-level exported declarations for ambiguity checks and static type closure, without promoting them to domain graph roots.
|
||||
|
||||
```ts
|
||||
interface SourceLocation {
|
||||
readonly file: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
}
|
||||
|
||||
interface EventModel {
|
||||
readonly location: SourceLocation
|
||||
readonly text: string
|
||||
}
|
||||
```
|
||||
|
||||
Repository-wide analysis supports building bounded `ts.Program` instances in package batches, then merging them through source-location-stable graph ids into a face model equivalent to monolithic analysis. This capability changes only the memory boundary of the compiler program; it does not change package, declaration, or type graph semantics.
|
||||
|
||||
All information required by the projector must come from `WorkspaceModel` or `TypeGraph`. If a fact required for character-for-character compatibility cannot be expressed by the model, extend the standard model; do not reintroduce `ts.Node`, `ts.Symbol`, or `ts.TypeChecker` in the projector or script.
|
||||
|
||||
## Character-for-Character Migration Oracle
|
||||
|
||||
Before migration, retain the three texts produced by the old generator against the same source state. After migration, run the new analyzer and projector and require the three outputs to be byte-for-byte identical. Newlines, spaces, ordering, JSDoc, source pointers, and generated headers are all part of the comparison.
|
||||
|
||||
`pnpm run verify-cordis-catalog` retains its `--check` mode, which reads the three committed artifacts and compares them directly with the newly computed results. A missing file or any differing character makes the artifact stale, and the error points to the single `pnpm run gen-cordis-catalog` repair command.
|
||||
|
||||
Tests pin both of the following layers:
|
||||
|
||||
- Typert fixture snapshots pin the `WorkspaceModel`, `TypeGraph`, JS, DTS, and Zod outputs, proving the behavior of the standard model and general-purpose emitters.
|
||||
- Cordis catalog tests or snapshots pin the projector's three complete texts, proving that the repository-specific product projection does not bypass the standard model and providing directly reviewable textual evidence.
|
||||
|
||||
The three committed artifacts are the migration oracle between the old and new implementations and the continuing freshness oracle after migration. The old `gen-cordis-api` AST collector is removed. The scripts and commands with that name remain only as compatibility entry points for the unified projector because the generated file header itself contains the command; retaining the entry point preserves the character-for-character oracle without creating a second source of truth.
|
||||
|
||||
## Exact Change List
|
||||
|
||||
### Typert Generator
|
||||
|
||||
- Add the locations, authored declaration text, parameter initializers, export status, and top-level source declaration index needed for character-for-character projection, with coverage in analyzer and model snapshots.
|
||||
- Support bounded package-batch analysis and prove that direct and batched models are equivalent.
|
||||
- Confirm that the catalog's required service declarations, public instance members, JSDoc, generics, inheritance, and referenced types are all available from the model.
|
||||
- Keep the TypeScript compiler API encapsulated within the analyzer; the public model and projector inputs do not expose compiler objects.
|
||||
|
||||
### Cordis Catalog Projector
|
||||
|
||||
- Select the complete set of Cordis services and events from the host `WorkspaceModel`.
|
||||
- Preserve the old generator's JSDoc rules: events must have `@mode` and payload `@param` tags; service methods must have a matching `@param` for every parameter; non-void returns must have `@returns`.
|
||||
- Compute the type links used by signatures and the transitive public type closure required by `tool-cordis` from the type graph.
|
||||
- Receive caller-maintained type classifications and the inherited surface through an explicit `CordisCatalogPolicy`; do not maintain the repository documentation taxonomy inside the generator package.
|
||||
- Preserve the existing output rules for source pointers, signatures, summaries, ordering, declaration truncation, and the inherited context catalog.
|
||||
- Project once and render the events Markdown, services Markdown, and TypeScript API catalog, preventing drift between documentation and tool data.
|
||||
|
||||
### Commands and Consumers
|
||||
|
||||
- `scripts/gen-cordis-catalog.ts` maintains repository policy data, assembles the analyzer and projector, and writes/checks all three artifacts together. Parsing, validation, and rendering logic lives in the generator's dedicated Cordis source file and is exported uniformly from the package root entry point.
|
||||
- Narrow `scripts/gen-cordis-api.ts` to a logic-free compatibility entry point for the unified CLI; the root `gen-cordis-api` and `verify-cordis-api` aliases point to that entry point.
|
||||
- Restore the static catalog default in `tool-cordis` and remove its dependencies on `ctx.typert`, `typert-registry`, and runtime package-model completeness.
|
||||
- `gen-doc-graphs` obtains the projector's model-level result once and reuses its services and events; it must not continue to import the AST collector or analyze the repository again.
|
||||
|
||||
### Narrow the Scope of Phase-One Changes
|
||||
|
||||
- Remove the newly added `./typert` and `./client/typert` exports and `lib/typert.*` files from product plugin package.json files.
|
||||
- Remove `typert-registry` and `typert-loader` assembly from examples.
|
||||
- Normal build/typecheck does not run repository-wide `gen-typert` or require product-package Typert artifacts to exist before it runs on a clean tree.
|
||||
- Retain `packages/typert/generator`, `packages/typert/registry`, and `packages/typert/loader`, along with their independent fixture, emitter, and runtime registration tests.
|
||||
|
||||
## Future Extensions
|
||||
|
||||
The runtime registry remains the receiving and query layer for generated JS/Zod, and the loader remains the automatic loading mechanism; neither supplies data to the first-phase static catalog. When product packages need runtime reflection, they can opt in by publishing `package/typert` and `package/client/typert`, which the loader then registers with `ctx.typert`.
|
||||
|
||||
Future integration does not change the phase-one layering: only the analyzer handles TypeScript, the standard model serves both static generation and scan analysis, and the emitter produces runtime artifacts from that same model. Whether to extend publication to more packages, enable the loader by default, or extend the runtime registry's query capabilities are separate review decisions and remain decoupled from the Cordis catalog migration.
|
||||
133
docs/typert-catalog-integration-design.zh.md
Normal file
133
docs/typert-catalog-integration-design.zh.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Typert catalog 接入设计
|
||||
|
||||
[English](typert-catalog-integration-design.md) | 中文
|
||||
|
||||
## 现状与问题
|
||||
|
||||
Typert 已经具备独立的 host/client `FaceModel`、可显式跨 face 引用的 `TypeGraph`,以及 service、event、`@typert object`、泛型、继承和 External 类型的分析能力。TypeScript compiler API 只应负责把源码转换成这套标准模型;后续消费者不应再次遍历 TypeScript AST。
|
||||
|
||||
仓库目前有两条直接分析 TypeScript 源码的 catalog 链路:`tool-cordis` 使用的静态 API catalog,以及 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md` 的生成与 freshness gate。它们分析的是同一批 service、event 和相关类型,却分别维护收集与渲染逻辑,不能证明 Typert 模型足以承载现有业务语义。
|
||||
|
||||
第一阶段的目标是让这两条链路共同消费 Typert 模型,并保持三份已提交产物与迁移前字符级一致:
|
||||
|
||||
- `docs/cordis-catalog/events.md`
|
||||
- `docs/cordis-catalog/services.md`
|
||||
- `packages/cordis/tool-cordis/src/api-catalog.ts`
|
||||
|
||||
本阶段不要求业务插件发布 Typert 子路径,不要求示例应用加载 Typert,也不改变 `tool-cordis` 的运行时依赖关系。
|
||||
|
||||
## 可选路径
|
||||
|
||||
### 运行时 registry 驱动 `tool-cordis`
|
||||
|
||||
每个插件发布并加载 Typert 产物,`tool-cordis` 再从 `ctx.typert` 读取当前运行时模型。这条路径可以反映实际加载的插件集合,但会要求所有参与 catalog 的业务包增加 package exports、生成产物、registry contribution 和应用装配,接入面远大于当前要验证的分析能力。
|
||||
|
||||
### 全仓发布 Typert 产物后静态汇总
|
||||
|
||||
所有业务包在普通 build/typecheck 中生成 host/client JS 与 DTS,再由 catalog 生成器汇总这些产物。这条路径能够提前建立完整的发布协议,但会同时修改大量 package manifest 和构建拓扑,使 catalog 迁移与 Typert 的全仓发布绑定。
|
||||
|
||||
### 构建期分析后投影 catalog
|
||||
|
||||
`WorkspaceAnalyzer` 从 host TypeScript project 构建 `WorkspaceModel` 与 `TypeGraph`,仓库专用的 `CordisCatalogProjector` 只消费该模型并生成三份文本。`tool-cordis` 继续导入已提交的静态 `api-catalog.ts`,运行时不需要 Typert service。
|
||||
|
||||
本阶段采用构建期投影。它直接验证 Typert 标准模型能否替代现有 AST collector,同时把运行时 publication 和自动加载留在独立的后续决策中。
|
||||
|
||||
## 第一阶段架构
|
||||
|
||||
```text
|
||||
tsconfig.host.json
|
||||
│
|
||||
▼
|
||||
WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界
|
||||
│
|
||||
▼
|
||||
WorkspaceModel + TypeGraph
|
||||
│
|
||||
▼
|
||||
CordisCatalogProjector ── 不依赖 TypeScript AST
|
||||
├── docs/cordis-catalog/events.md
|
||||
├── docs/cordis-catalog/services.md
|
||||
└── packages/cordis/tool-cordis/src/api-catalog.ts
|
||||
```
|
||||
|
||||
各对象的职责如下:
|
||||
|
||||
- `WorkspaceAnalyzer` 负责 package、export、service、event、类型声明和引用关系的分析,并产生 compiler-independent model。
|
||||
- `WorkspaceModel` 与 `TypeGraph` 是所有生成和扫描分析共用的标准数据结构,保留开发者写出的泛型、继承和类型树,不保存 TypeScript AST。
|
||||
- `@deepseek-ai/dsh-typert-generator` 根入口导出的 `CordisCatalogProjector` 负责模型驱动的选择、排序、摘要、源位置、JSDoc 完整性、类型链接闭包和三种文本格式;实现仍单独放在 Cordis catalog 专用文件中,但不形成额外的 package subpath,也不内置仓库类型名单。
|
||||
- `scripts/gen-cordis-catalog.ts` 提供 `LINK_MAP`、`FOUNDATION_TYPE_NAMES`、`TYPE_LINK_EXEMPTIONS` 和 inherited Cordis 清单,通过 `CordisCatalogPolicy` 显式注入 projector,并负责 write/check 的命令行行为;vendor Cordis core 页面仍由独立的 pinned-source projector 生成。
|
||||
- `tool-cordis` 只导入静态 `api-catalog.ts`,不依赖 `typert-registry` 或 `typert-loader`。
|
||||
|
||||
`CordisCatalogProjector` 是仓库业务消费者,不进入 Typert 通用模型。新增其他类别时,先扩展标准模型,再增加对应 projector;Typert analyzer 不吸收 Cordis 文档格式或 `tool-cordis` 展示逻辑。
|
||||
|
||||
## 模型补充
|
||||
|
||||
Catalog 的字符级投影除了类型结构,还需要开发者写下的声明形式和精确源码位置。标准模型因此保留 event/service location、event/member 的 body-free text、parameter initializer,以及 type declaration 的 export 状态和 canonical text;`SourceDeclarationModel` 另外索引顶层导出声明,供歧义检查和静态类型闭包使用,但不把它们提升为业务 graph root。
|
||||
|
||||
```ts
|
||||
interface SourceLocation {
|
||||
readonly file: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
}
|
||||
|
||||
interface EventModel {
|
||||
readonly location: SourceLocation
|
||||
readonly text: string
|
||||
}
|
||||
```
|
||||
|
||||
全仓分析支持按 package 分批构建有界 `ts.Program`,再依靠源码位置稳定的 graph id 合并为与一次性分析等价的 face model。该能力只改变 compiler program 的内存边界,不改变 package、declaration 或 type graph 语义。
|
||||
|
||||
projector 所需信息必须来自 `WorkspaceModel` 或 `TypeGraph`。如果字符级兼容需要的事实无法从模型表达,应补充标准模型;不得在 projector 或脚本中重新引入 `ts.Node`、`ts.Symbol` 或 `ts.TypeChecker`。
|
||||
|
||||
## 字符级迁移 oracle
|
||||
|
||||
迁移前,在同一份源码状态下保留旧生成器产生的三份文本。迁移后运行新的 analyzer 与 projector,要求三份输出逐字节相等;换行、空格、排序、JSDoc、source pointer 和生成头都属于比较内容。
|
||||
|
||||
`pnpm run verify-cordis-catalog` 的 `--check` 模式继续读取三份 committed artifact,并与本次计算结果直接比较。任一文件缺失或任一字符不同都视为 stale,错误信息指向统一的 `pnpm run gen-cordis-catalog` 修复命令。
|
||||
|
||||
测试同时固定以下两层:
|
||||
|
||||
- Typert fixture snapshots 固定 `WorkspaceModel`、`TypeGraph`、JS、DTS 与 Zod 输出,证明标准模型和通用 emitter 的行为。
|
||||
- Cordis catalog 测试或 snapshot 固定 projector 的三份完整文本,证明仓库业务投影没有绕过标准模型,并给出可直接评审的文本证据。
|
||||
|
||||
三份 committed artifact 是旧实现与新实现的迁移 oracle,也是迁移完成后的持续 freshness oracle。旧 `gen-cordis-api` AST collector 被删除;同名脚本和命令只作为统一 projector 的兼容入口保留,因为生成文件头本身包含该命令,保留入口可以维持字符级 oracle 而不产生第二套真源。
|
||||
|
||||
## 精确改造清单
|
||||
|
||||
### Typert generator
|
||||
|
||||
- 补齐字符级投影所需的 location、authored declaration text、parameter initializer、export 状态和顶层 source declaration index,并在 analyzer 与 model snapshots 中覆盖。
|
||||
- 支持有界 package batch 分析,并证明 direct 与 batched model 等价。
|
||||
- 确认 catalog 所需的 service 声明、public instance member、JSDoc、泛型、继承和引用类型均可从 model 读取。
|
||||
- 保持 TypeScript compiler API 封装在 analyzer 内;公共 model 和 projector 输入不暴露 compiler 对象。
|
||||
|
||||
### Cordis catalog projector
|
||||
|
||||
- 从 host `WorkspaceModel` 选择完整的 Cordis service/event 集合。
|
||||
- 保留旧生成器的 JSDoc 规则:event 必须有 `@mode` 和 payload `@param`,service method 必须有参数对应的 `@param`,非 void 返回必须有 `@returns`。
|
||||
- 从 type graph 计算签名涉及的类型链接和 `tool-cordis` 所需的传递 public type closure。
|
||||
- 通过显式 `CordisCatalogPolicy` 接收调用方维护的类型分类和 inherited surface,不在 generator 包内维护仓库文档 taxonomy。
|
||||
- 保留 source pointer、签名、摘要、排序、声明截断和 inherited context catalog 的既有输出规则。
|
||||
- 一次投影并渲染 events Markdown、services Markdown 与 TypeScript API catalog,避免文档和工具数据漂移。
|
||||
|
||||
### 命令与消费方
|
||||
|
||||
- `scripts/gen-cordis-catalog.ts` 维护仓库 policy 数据、组装 analyzer/projector,并同时 write/check 三份产物;解析、校验和渲染逻辑位于 generator 的 Cordis 专用源文件,并统一从 package 根入口导出。
|
||||
- 将 `scripts/gen-cordis-api.ts` 收窄为统一 CLI 的无逻辑兼容入口;根目录的 `gen-cordis-api`、`verify-cordis-api` aliases 指向该入口。
|
||||
- `tool-cordis` 恢复静态 catalog 默认值,移除对 `ctx.typert`、`typert-registry` 和运行时 package model 完整性的依赖。
|
||||
- `gen-doc-graphs` 一次取得 projector 的 model-level 结果并复用 services/events,不能继续导入 AST collector 或重复分析全仓。
|
||||
|
||||
### 收窄本阶段改动面
|
||||
|
||||
- 撤销业务插件 package.json 中新增的 `./typert`、`./client/typert` exports 和 `lib/typert.*` files。
|
||||
- 撤销 examples 中的 `typert-registry`、`typert-loader` 装配。
|
||||
- 普通 build/typecheck 不运行全仓 `gen-typert`,也不要求 clean tree 预先存在业务包 Typert artifact。
|
||||
- 保留 `packages/typert/generator`、`packages/typert/registry`、`packages/typert/loader` 及其独立 fixture、emitter 和 runtime registration 测试。
|
||||
|
||||
## 后续扩展
|
||||
|
||||
Runtime registry 继续作为生成 JS/Zod 后的接收与查询层,loader 继续作为自动装载机制;两者不承担第一阶段静态 catalog 的数据来源。业务包需要运行时反射时,可以按 package opt-in 发布 `package/typert` 与 `package/client/typert`,再由 loader 注册到 `ctx.typert`。
|
||||
|
||||
后续接入不改变本阶段的分层:TypeScript 只进入 analyzer,标准模型同时服务静态生成与扫描分析,runtime artifact 由 emitter 从同一模型产生。是否把更多 package 接入 publication、是否默认启用 loader,以及 runtime registry 最终提供哪些查询能力,分别评审,不与 Cordis catalog 迁移捆绑。
|
||||
@@ -1,188 +0,0 @@
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import sonarjs from 'eslint-plugin-sonarjs'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
// Strict type-aware correctness rules plus repository formatting. Tests/examples relax deliberate
|
||||
// mock unsafety; vendored sources retain upstream style and receive only selected safety checks.
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/lib/**',
|
||||
'**/node_modules/**',
|
||||
'**/.sessions/**',
|
||||
'.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources
|
||||
'**/.doc-typecheck-*/**',
|
||||
'**/.node-next-types-*/**',
|
||||
'website/.generated/**',
|
||||
'vendor/**', // vendored source keeps upstream style and idioms
|
||||
'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md)
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
'*.config.ts', // root tool configs (vitest, tsdown) — no project service
|
||||
'apps/*/*.config.ts', // app build configs — outside their project programs
|
||||
'**/tsdown.config.ts', // package build configs — in no tsconfig program, and TS syntax breaks the parserless fallback
|
||||
'packages/client/tsdown.client.ts', // shared client build preset, same standing
|
||||
],
|
||||
},
|
||||
|
||||
// --- our packages: full strictness -------------------------------------
|
||||
{
|
||||
files: [
|
||||
'packages/*/*/src/**/*.{ts,tsx}',
|
||||
'apps/*/src/**/*.{ts,tsx}',
|
||||
'examples/**/*.{ts,tsx}',
|
||||
'scripts/**/*.{ts,tsx}',
|
||||
'website/**/*.{ts,tsx}',
|
||||
],
|
||||
extends: [
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// One project service resolves each file to its owning tsconfig and shares dependency
|
||||
// graphs. Per-package programs duplicated path-mapped and Cordis closures, reaching ~5 GB.
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// The bug class this repo cares most about: lost promises in the loop.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'@typescript-eslint/switch-exhaustiveness-check': ['error', {
|
||||
considerDefaultExhaustiveForUnions: true,
|
||||
}],
|
||||
'@typescript-eslint/no-unnecessary-condition': ['error', {
|
||||
allowConstantLoopConditions: true,
|
||||
}],
|
||||
// `any` requires a justification comment — enforced as: no bare casts.
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
// Style points where the codebase intentionally diverges from preset:
|
||||
'@typescript-eslint/no-namespace': 'off', // Cordis Config-namespace idiom
|
||||
'@typescript-eslint/no-empty-object-type': 'off', // merge-extensible maps
|
||||
'@typescript-eslint/no-invalid-void-type': 'off', // event signatures
|
||||
'@typescript-eslint/restrict-template-expressions': ['error', {
|
||||
allowNumber: true,
|
||||
allowBoolean: true,
|
||||
}],
|
||||
// `void foo()` in arrow listeners is our idiom for intentional fire-and-forget
|
||||
'no-void': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// --- examples: demo code conforms to async interfaces without awaiting ---
|
||||
{
|
||||
files: ['examples/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// --- tests: same rules, minus the friction that fights test ergonomics --
|
||||
{
|
||||
files: [
|
||||
'packages/*/*/tests/**/*.{ts,tsx}',
|
||||
'apps/*/tests/**/*.{ts,tsx}',
|
||||
'examples/*/tests/**/*.{ts,tsx}',
|
||||
'scripts/**/*.spec.{ts,tsx}',
|
||||
],
|
||||
extends: [
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// Same shared project service as the src block: test files resolve
|
||||
// through the root solution to tsconfig.host.json (its include covers
|
||||
// every host tests/ tree).
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off', // assertions follow expect()s
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
'@typescript-eslint/require-await': 'off', // mock execute() signatures
|
||||
'@typescript-eslint/no-empty-function': 'off', // stub agents
|
||||
'@typescript-eslint/only-throw-error': 'off', // testing non-Error throws
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// --- client tests: the root program excludes packages/client (host/client
|
||||
// Context merges collide), so the shared project service cannot resolve
|
||||
// them — parse these through the client aggregate explicitly.
|
||||
{
|
||||
files: [
|
||||
'packages/client/*/tests/**/*.{ts,tsx}',
|
||||
'scripts/client-bundle-purity.spec.ts',
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: false,
|
||||
project: ['./tsconfig.client.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- file-local duplication (all owned TypeScript) ---------------------
|
||||
{
|
||||
files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'],
|
||||
plugins: { sonarjs },
|
||||
rules: {
|
||||
// Cross-file clones are covered separately by jscpd.
|
||||
'sonarjs/duplicates-in-character-class': 'error',
|
||||
'sonarjs/no-all-duplicated-branches': 'error',
|
||||
'sonarjs/no-duplicate-in-composite': 'error',
|
||||
'sonarjs/no-duplicate-test-title': 'error',
|
||||
'sonarjs/no-identical-conditions': 'error',
|
||||
'sonarjs/no-identical-expressions': 'error',
|
||||
'sonarjs/no-identical-functions': 'error',
|
||||
'sonarjs/no-duplicated-branches': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// --- formatting (everything we own) -------------------------------------
|
||||
{
|
||||
files: [
|
||||
'packages/**/*.{ts,tsx}',
|
||||
'apps/**/*.{ts,tsx}',
|
||||
'examples/**/*.{ts,tsx}',
|
||||
'scripts/**/*.{ts,tsx}',
|
||||
'website/**/*.{ts,tsx}',
|
||||
'eslint.config.mjs',
|
||||
],
|
||||
plugins: { '@stylistic': stylistic },
|
||||
rules: {
|
||||
'@stylistic/indent': ['error', 2],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/eol-last': ['error', 'always'],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
|
||||
'@stylistic/member-delimiter-style': ['error', {
|
||||
multiline: { delimiter: 'none' },
|
||||
singleline: { delimiter: 'semi', requireLast: false },
|
||||
}],
|
||||
'@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
|
||||
},
|
||||
},
|
||||
)
|
||||
59
eslint.format.config.mjs
Normal file
59
eslint.format.config.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import parser from '@typescript-eslint/parser'
|
||||
|
||||
// Oxlint's JavaScript-plugin compatibility layer reports these rules but does
|
||||
// not execute their fixers. Keep this config formatting-only: Oxlint remains
|
||||
// the authoritative repository linter after this pass applies safe fixes.
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/lib/**',
|
||||
'**/node_modules/**',
|
||||
'**/.sessions/**',
|
||||
'.claude/**',
|
||||
'**/.doc-typecheck-*/**',
|
||||
'**/.node-next-types-*/**',
|
||||
// Do not mirror Oxlint's contract-fixture ignore: those files must reach this formatter.
|
||||
'website/.generated/**',
|
||||
'vendor/**',
|
||||
'native/**',
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
'**/*.config.ts',
|
||||
'packages/client/tsdown.client.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx,mts,cts}'],
|
||||
languageOptions: {
|
||||
parser,
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/indent': ['error', 2],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/eol-last': ['error', 'always'],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
|
||||
'@stylistic/member-delimiter-style': ['error', {
|
||||
multiline: { delimiter: 'none' },
|
||||
singleline: { delimiter: 'semi', requireLast: false },
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
// TypeGraph coverage must retain source-authored syntax that the normal quote rule forbids.
|
||||
files: ['packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'],
|
||||
rules: {
|
||||
'@stylistic/quotes': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -226,10 +226,16 @@ const SCENARIOS: Scenario[] = [
|
||||
headerClass: 'advanced',
|
||||
configPath: ADVANCED_CONFIG,
|
||||
},
|
||||
// Prompt-submit blocks are authored keylessly. Admission rejects before a
|
||||
// turn opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
// Prompt-submit blocks are authored keylessly with malformed matcher fields,
|
||||
// which these matcherless events must ignore. Admission rejects before a turn
|
||||
// opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
// Each invalid matcher follows a runnable prompt blocker. Reaching the replay
|
||||
// model without any hook audit rows proves config loading is atomic through
|
||||
// the real Loader/app path, rather than retaining the earlier valid group.
|
||||
{ name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
{ name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
|
||||
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
|
||||
// SessionStart/SubagentStart are excluded because detached injection races log
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,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],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,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],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"9c670f1c-3508-4b98-9cae-21f363652d6e"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":4,"time":1784903324927,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":5,"time":1784903324928,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: model-only-skill
|
||||
description: Prove user-disabled skills remain available to the model.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
Follow these model-only snapshot instructions.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: user-only-skill
|
||||
description: Prove model-disabled skills stay outside the model catalog.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Follow these user-only snapshot instructions.
|
||||
@@ -0,0 +1,5 @@
|
||||
{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
@@ -0,0 +1,157 @@
|
||||
=== skill autocomplete ===
|
||||
terminal 100x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH TUI snapshot"
|
||||
cursor hidden column=13 viewportRow=5 bufferRow=5
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Recorded replay: skill-invocation-policy"
|
||||
style 1-40 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-51 fg=bright-magenta bold
|
||||
style 54-70 dim
|
||||
style 73-77 dim
|
||||
style 80-89 dim
|
||||
5| " dsh > /skill "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 13-13 inverse
|
||||
6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. "
|
||||
style 7-78 fg=bright-magenta
|
||||
7-35| <blank>
|
||||
|
||||
|
||||
=== loaded exact invocation ===
|
||||
terminal 100x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "<skill name=\"user-only-skill\"> Reference — DSH TUI snapshot"
|
||||
cursor hidden column=7 viewportRow=30 bufferRow=30
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " <skill name=\"user-only-skill\"> Reference"
|
||||
style 1-40 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "<skill name=\"user-only-skill\"> "
|
||||
6| "References in this skill are relative to "
|
||||
7| "/workspace/project/.agents/skills/user-only-skill. "
|
||||
8| " "
|
||||
9| "USER-ONLY BODY "
|
||||
10| "</skill> "
|
||||
11| <blank>
|
||||
12| "Context · dsh-tool-skill"
|
||||
style 0-23 dim
|
||||
13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
|
||||
style 0-99 dim
|
||||
14| "session: "
|
||||
style 0-7 dim
|
||||
15| " "
|
||||
16| "<available_skills> "
|
||||
style 0-17 dim
|
||||
17| "- `model-only-skill`: Model-only assembled snapshot skill. "
|
||||
style 0-57 dim
|
||||
18| "</available_skills> "
|
||||
style 0-18 dim
|
||||
19| " "
|
||||
20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
|
||||
style 0-99 dim
|
||||
21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
|
||||
style 0-99 dim
|
||||
22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's "
|
||||
style 0-99 dim
|
||||
23| "instructions until it has been loaded. "
|
||||
style 0-37 dim
|
||||
24| <blank>
|
||||
25| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
26| "USER-ONLY SKILL LOADED "
|
||||
27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
28| <blank>
|
||||
29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context"
|
||||
style 0-51 fg=bright-magenta bold
|
||||
style 54-70 dim
|
||||
style 73-77 dim
|
||||
style 80-89 dim
|
||||
30| " dsh ◍ "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
31-35| <blank>
|
||||
|
||||
|
||||
=== denied exact invocation ===
|
||||
terminal 100x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "<skill name=\"user-only-skill\"> Reference — DSH TUI snapshot"
|
||||
cursor hidden column=7 viewportRow=32 bufferRow=32
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " <skill name=\"user-only-skill\"> Reference"
|
||||
style 1-40 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "<skill name=\"user-only-skill\"> "
|
||||
6| "References in this skill are relative to "
|
||||
7| "/workspace/project/.agents/skills/user-only-skill. "
|
||||
8| " "
|
||||
9| "USER-ONLY BODY "
|
||||
10| "</skill> "
|
||||
11| <blank>
|
||||
12| "Context · dsh-tool-skill"
|
||||
style 0-23 dim
|
||||
13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
|
||||
style 0-99 dim
|
||||
14| "session: "
|
||||
style 0-7 dim
|
||||
15| " "
|
||||
16| "<available_skills> "
|
||||
style 0-17 dim
|
||||
17| "- `model-only-skill`: Model-only assembled snapshot skill. "
|
||||
style 0-57 dim
|
||||
18| "</available_skills> "
|
||||
style 0-18 dim
|
||||
19| " "
|
||||
20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
|
||||
style 0-99 dim
|
||||
21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
|
||||
style 0-99 dim
|
||||
22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's "
|
||||
style 0-99 dim
|
||||
23| "instructions until it has been loaded. "
|
||||
style 0-37 dim
|
||||
24| <blank>
|
||||
25| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
26| "USER-ONLY SKILL LOADED "
|
||||
27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
28| <blank>
|
||||
29| "Skill \"model-only-skill\" is not available for user invocation. "
|
||||
style 0-61 fg=yellow
|
||||
30| <blank>
|
||||
31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context"
|
||||
style 0-51 fg=bright-magenta bold
|
||||
style 54-70 dim
|
||||
style 73-77 dim
|
||||
style 80-89 dim
|
||||
32| " dsh ◍ "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
33-35| <blank>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: model-only-skill
|
||||
description: Model-only assembled snapshot skill.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
MODEL-ONLY BODY MUST NOT LOAD
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user