mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer
This commit is contained in:
@@ -304,6 +304,12 @@
|
||||
"process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5",
|
||||
"process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936",
|
||||
"process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e",
|
||||
"process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925",
|
||||
"process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501",
|
||||
"process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7",
|
||||
"process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d",
|
||||
"process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857",
|
||||
"process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032",
|
||||
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
|
||||
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
|
||||
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
|
||||
|
||||
+2
-2
@@ -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-08-04-forward-only-pr-issue-status.md
|
||||
2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76
|
||||
2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752
|
||||
2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30
|
||||
2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Forward-only PR-to-Issue status projection
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: PR 到 Issue 的状态仅向前投射
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
[English](2026-08-04-forward-only-pr-issue-status.md) | 中文
|
||||
|
||||
+2
-2
@@ -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-08-08-review-driven-issue-lifecycle-triggers.md
|
||||
2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83
|
||||
2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507
|
||||
2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c
|
||||
2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Review-driven Issue lifecycle triggers
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 由评审驱动的 Issue 生命周期触发器
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-10
|
||||
|
||||
[English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Agent Note: Experimental and internal package group
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-experimental-plugin-package-group.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release.
|
||||
|
||||
## Decision
|
||||
|
||||
The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental/<pkg>/` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-<pkg>`.
|
||||
|
||||
The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support.
|
||||
|
||||
Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies.
|
||||
|
||||
Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements.
|
||||
|
||||
The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries.
|
||||
|
||||
**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary.
|
||||
|
||||
**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them.
|
||||
|
||||
## Consequences
|
||||
|
||||
The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary.
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Agent Note: 实验性与内部专用包分组
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-experimental-plugin-package-group.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。
|
||||
|
||||
## 决策
|
||||
|
||||
[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental/<pkg>/`。包名仍为 `@deepseek-ai/dsh-<pkg>`。
|
||||
|
||||
该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。
|
||||
|
||||
官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。
|
||||
|
||||
实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。
|
||||
|
||||
尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。
|
||||
|
||||
**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。
|
||||
|
||||
**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。
|
||||
|
||||
## 后果
|
||||
|
||||
该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.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/architecture/2026-07-29-package-regrouping.md
|
||||
2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772
|
||||
2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f
|
||||
2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8
|
||||
2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50
|
||||
|
||||
@@ -58,7 +58,7 @@ The moves landed as pure `git mv` moves, so rename detection carries the history
|
||||
|
||||
A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name.
|
||||
|
||||
`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision.
|
||||
`client/` and `host/` were out of scope and are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Status: implemented
|
||||
|
||||
组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml` 与 `tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。
|
||||
|
||||
`client/` 与 `host/` 不在本次范围内,保持不变。`experimental/` 组提案(PR #844)与本案正交:它是发布边界容器,不是聚类决策。
|
||||
`client/` 与 `host/` 不在本次范围内,保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+3
-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 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md
|
||||
2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80
|
||||
2026-07-28-experimental-plugin-package-group.zh.md: 2d09451c5069a775906e5bc8748c334c29008164
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
|
||||
2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7
|
||||
2026-08-10-session-log-version-mechanism.zh.md: c47670f2de77773c17c9595eff442bf7f1e8ec3e
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-session-log-version-mechanism.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent.
|
||||
|
||||
**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
|
||||
|
||||
**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
|
||||
|
||||
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
|
||||
|
||||
## Consequences
|
||||
|
||||
What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises.
|
||||
- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
|
||||
- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
|
||||
- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note:Session log 版本机制:单调整数、升级器链、逐事件可忽略标记
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-session-log-version-mechanism.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。
|
||||
|
||||
## 决定
|
||||
|
||||
**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。
|
||||
|
||||
**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
|
||||
|
||||
**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
|
||||
|
||||
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
|
||||
|
||||
## 影响
|
||||
|
||||
v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。
|
||||
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
|
||||
- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
|
||||
- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。
|
||||
+6
@@ -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/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: One selection rule keeps subagent output past an empty terminal message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
|
||||
|
||||
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output.
|
||||
|
||||
## Verification
|
||||
|
||||
The keyless SDK backend test uses `FAKE_EMPTY_MESSAGE` to emit a usage-only terminal message. The `subagent-max-tokens-partial` ACP snapshot records a child that streams text and a tool call, ends at a tool-only max-tokens step with an empty usage message in its durable log, and returns the partial text through the parent's errored tool result. Unit coverage checks empty terminal messages, cancellation, message ordering, textless non-empty messages, and exclusion of tool-result content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fix each consumer in place without a shared helper.** Rejected: three independent selections had diverged, while observers of one run must agree on its output.
|
||||
|
||||
**Stop the loop from appending the empty message.** Rejected: the message records usage and preserves the step in the durable log ("model-visible ⟺ logged"); changing session events to address output selection would affect every replay and projection consumer.
|
||||
|
||||
**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short.
|
||||
|
||||
## Consequences
|
||||
|
||||
Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children retain text streamed before the abort; one-shot and continuable `subagent/end` events agree with `SubagentResult.output`. A message whose content is non-empty but textless, such as reasoning-only content, is selected instead of streamed text because the rule tests content length rather than text presence. A non-empty message is also selected instead of text streamed after it: a child cancelled while streaming a later step reports its earlier complete message, while the stop reason records the truncation.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。
|
||||
|
||||
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。
|
||||
|
||||
## 验证
|
||||
|
||||
无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。
|
||||
|
||||
**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。
|
||||
|
||||
**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。
|
||||
|
||||
## 后果
|
||||
|
||||
被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。
|
||||
@@ -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-08-10-event-directed-pr-review-status.md
|
||||
2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1
|
||||
2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Event-directed PR review status commands
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-event-directed-pr-review-status.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again.
|
||||
|
||||
A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need.
|
||||
|
||||
## Decision
|
||||
|
||||
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed.
|
||||
|
||||
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
|
||||
|
||||
The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler.
|
||||
|
||||
[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.
|
||||
|
||||
## Verification
|
||||
|
||||
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs.
|
||||
|
||||
**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes.
|
||||
|
||||
**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition.
|
||||
|
||||
**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command.
|
||||
|
||||
## Consequences
|
||||
|
||||
A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged.
|
||||
|
||||
The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 由事件直接指定的 PR 评审状态命令
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-event-directed-pr-review-status.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。
|
||||
|
||||
单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。
|
||||
|
||||
## 决策
|
||||
|
||||
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。
|
||||
|
||||
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
|
||||
|
||||
处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。
|
||||
|
||||
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
|
||||
|
||||
## 验证
|
||||
|
||||
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。
|
||||
|
||||
**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`。
|
||||
|
||||
**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。
|
||||
|
||||
**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。
|
||||
|
||||
## 后果
|
||||
|
||||
即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。
|
||||
|
||||
投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。
|
||||
+6
@@ -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/simplification/2026-08-11-remove-empty-experimental-package-group.md
|
||||
2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37
|
||||
2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Remove the empty experimental package group
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them.
|
||||
|
||||
The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists.
|
||||
|
||||
## Decision
|
||||
|
||||
The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role.
|
||||
|
||||
A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules.
|
||||
|
||||
This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism.
|
||||
|
||||
**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class.
|
||||
|
||||
**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism.
|
||||
|
||||
**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin.
|
||||
|
||||
**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path.
|
||||
|
||||
The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 移除空的实验性包分组
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。
|
||||
|
||||
原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。
|
||||
|
||||
## 决策
|
||||
|
||||
包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。
|
||||
|
||||
如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。
|
||||
|
||||
本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。
|
||||
|
||||
**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。
|
||||
|
||||
**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。
|
||||
|
||||
**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。
|
||||
|
||||
**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。
|
||||
|
||||
## 后果
|
||||
|
||||
包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。
|
||||
|
||||
第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。
|
||||
@@ -3,6 +3,7 @@
|
||||
"repository": "deepseek-harness",
|
||||
"projectNumber": 1,
|
||||
"projectTitle": "DSH Issue Management",
|
||||
"lifecycleActor": "dsh-issue-management",
|
||||
"priorityField": "Priority",
|
||||
"allowUnassignedOwner": true,
|
||||
"statuses": [
|
||||
|
||||
@@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([
|
||||
])
|
||||
const TERMINAL_STATUSES = new Set(['Done', 'No action'])
|
||||
const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
|
||||
const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
|
||||
'opened',
|
||||
'edited',
|
||||
'synchronize',
|
||||
'reopened',
|
||||
'labeled',
|
||||
'unlabeled',
|
||||
])
|
||||
|
||||
for (const status of ['In progress', 'In review']) {
|
||||
if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
|
||||
}
|
||||
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
|
||||
throw new Error('config.lifecycleActor 未设置')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Markdown outside balanced details elements.
|
||||
@@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a forward-only Issue status from the current PR phase.
|
||||
* @param {string|null} currentStatus Current Project status.
|
||||
* @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase.
|
||||
* @returns {string|null} Status to write, or null when no forward transition exists.
|
||||
* Translate a repository event into one resolving-Issue lifecycle command.
|
||||
* @param {string} eventName GitHub event name.
|
||||
* @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
|
||||
* @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
|
||||
*/
|
||||
export function nextResolvingIssueStatus(currentStatus, pull) {
|
||||
const target =
|
||||
!pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
|
||||
? 'In review'
|
||||
: 'In progress'
|
||||
export function resolvingIssueStatusCommand(eventName, event) {
|
||||
if (eventName === 'pull_request') {
|
||||
if (event.action === 'review_requested') return 'review-requested'
|
||||
return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
|
||||
}
|
||||
if (
|
||||
eventName === 'pull_request_review' &&
|
||||
event.action === 'submitted' &&
|
||||
event.review?.state?.toLowerCase() === 'changes_requested'
|
||||
) {
|
||||
return 'changes-requested'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan one event-directed resolving-Issue status transition.
|
||||
* @param {string|null} currentStatus Current Project status.
|
||||
* @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
|
||||
* @param {string|null} currentStatusActor Actor that last set the current Project status.
|
||||
* @returns {string|null} Status to write, or null when no permitted transition exists.
|
||||
*/
|
||||
export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
|
||||
let target
|
||||
if (command === 'review-requested') target = 'In review'
|
||||
else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
|
||||
else throw new Error(`未知 lifecycle command:${command}`)
|
||||
|
||||
const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
|
||||
const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
|
||||
if (
|
||||
command === 'changes-requested' &&
|
||||
currentStatus === 'In review' &&
|
||||
currentStatusActor === config.lifecycleActor
|
||||
) {
|
||||
return target
|
||||
}
|
||||
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
|
||||
}
|
||||
|
||||
@@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
async function projectContext(number) {
|
||||
async function projectContext(number, includeStatusActor = false) {
|
||||
const data = await graphql(
|
||||
`query($organization: String!, $repository: String!, $number: Int!, $project: Int!) {
|
||||
`query(
|
||||
$organization: String!
|
||||
$repository: String!
|
||||
$number: Int!
|
||||
$project: Int!
|
||||
$includeStatusActor: Boolean!
|
||||
) {
|
||||
organization(login: $organization) {
|
||||
projectV2(number: $project) {
|
||||
id
|
||||
@@ -413,6 +460,16 @@ async function projectContext(number) {
|
||||
repository(owner: $organization, name: $repository) {
|
||||
issue(number: $number) {
|
||||
id
|
||||
timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
|
||||
@include(if: $includeStatusActor) {
|
||||
nodes {
|
||||
... on ProjectV2ItemStatusChangedEvent {
|
||||
actor { login }
|
||||
project { id }
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
projectItems(first: 20, includeArchived: true) {
|
||||
nodes {
|
||||
id
|
||||
@@ -430,6 +487,7 @@ async function projectContext(number) {
|
||||
repository: config.repository,
|
||||
number,
|
||||
project: config.projectNumber,
|
||||
includeStatusActor,
|
||||
},
|
||||
)
|
||||
const project = data.organization?.projectV2
|
||||
@@ -439,7 +497,14 @@ async function projectContext(number) {
|
||||
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
|
||||
if (!statusField) throw new Error('Project 缺少 Status 字段')
|
||||
const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
|
||||
return { project, issue, statusField, item }
|
||||
const latestStatusEvent = issue.timelineItems?.nodes
|
||||
?.filter((event) => event?.project?.id === project.id)
|
||||
.at(-1)
|
||||
const statusActor =
|
||||
latestStatusEvent?.status === item?.fieldValueByName?.name
|
||||
? (latestStatusEvent.actor?.login ?? null)
|
||||
: null
|
||||
return { project, issue, statusField, item, statusActor }
|
||||
}
|
||||
|
||||
async function projectStatus(number) {
|
||||
@@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) {
|
||||
return errors
|
||||
}
|
||||
|
||||
async function pullRequestSnapshot(number) {
|
||||
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
|
||||
const [reviewRequests, reviews] = await Promise.all([
|
||||
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
|
||||
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
|
||||
])
|
||||
async function resolvingReferencesSnapshot(number, pull) {
|
||||
const references = parseReferences({
|
||||
body: pull.body ?? '',
|
||||
repository: `${config.organization}/${config.repository}`,
|
||||
@@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) {
|
||||
}
|
||||
return {
|
||||
number,
|
||||
isDraft: pull.draft,
|
||||
authorType: pull.user?.type ?? 'User',
|
||||
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
|
||||
reviewCount: reviews.length,
|
||||
labels: pull.labels.map((label) => label.name),
|
||||
references: retainIssueReferences(references, issues),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceResolvingIssues(pull) {
|
||||
async function pullRequestSnapshot(number) {
|
||||
const [pull, reviewRequests, reviews] = await Promise.all([
|
||||
api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
|
||||
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
|
||||
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
|
||||
])
|
||||
const resolving = await resolvingReferencesSnapshot(number, pull)
|
||||
return {
|
||||
...resolving,
|
||||
isDraft: pull.draft,
|
||||
authorType: pull.user?.type ?? 'User',
|
||||
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
|
||||
reviewCount: reviews.length,
|
||||
labels: pull.labels.map((label) => label.name),
|
||||
}
|
||||
}
|
||||
|
||||
async function lifecyclePullRequestSnapshot(number) {
|
||||
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
|
||||
return resolvingReferencesSnapshot(number, pull)
|
||||
}
|
||||
|
||||
async function transitionResolvingIssues(pull, command) {
|
||||
for (const number of pull.references.resolving) {
|
||||
const context = await projectContext(number)
|
||||
const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
|
||||
const context = await projectContext(number, command === 'changes-requested')
|
||||
const target = nextResolvingIssueStatus(
|
||||
context.item?.fieldValueByName?.name ?? null,
|
||||
command,
|
||||
context.statusActor,
|
||||
)
|
||||
if (!target) continue
|
||||
// TODO: Replace this latest-state guard with per-Issue serialization or a
|
||||
// conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
|
||||
@@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) {
|
||||
}
|
||||
|
||||
if (eventName === 'pull_request' || eventName === 'pull_request_review') {
|
||||
const pull = await pullRequestSnapshot(event.pull_request.number)
|
||||
await advanceResolvingIssues(pull)
|
||||
const command = resolvingIssueStatusCommand(eventName, event)
|
||||
if (!command) return
|
||||
const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
|
||||
await transitionResolvingIssues(pull, command)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
nextResolvingIssueStatus,
|
||||
parseReferences,
|
||||
retainIssueReferences,
|
||||
resolvingIssueStatusCommand,
|
||||
requiresPullRequestPolicy,
|
||||
validateBody,
|
||||
validateIssue,
|
||||
@@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('advances resolving Issues to the live PR phase', () => {
|
||||
const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 }
|
||||
const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
|
||||
const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 }
|
||||
const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
|
||||
|
||||
for (const status of ['Inbox', 'Backlog', 'Ready']) {
|
||||
assert.equal(nextResolvingIssueStatus(status, draft), 'In progress')
|
||||
assert.equal(nextResolvingIssueStatus(status, open), 'In progress')
|
||||
assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review')
|
||||
test('maps only explicit review handoffs to review status commands', () => {
|
||||
assert.equal(
|
||||
resolvingIssueStatusCommand('pull_request', {
|
||||
action: 'review_requested',
|
||||
}),
|
||||
'review-requested',
|
||||
)
|
||||
assert.equal(
|
||||
resolvingIssueStatusCommand('pull_request_review', {
|
||||
action: 'submitted',
|
||||
review: { state: 'changes_requested' },
|
||||
}),
|
||||
'changes-requested',
|
||||
)
|
||||
for (const state of ['approved', 'commented']) {
|
||||
assert.equal(
|
||||
resolvingIssueStatusCommand('pull_request_review', {
|
||||
action: 'submitted',
|
||||
review: { state },
|
||||
}),
|
||||
null,
|
||||
)
|
||||
}
|
||||
assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review')
|
||||
assert.equal(
|
||||
resolvingIssueStatusCommand('pull_request_review', {
|
||||
action: 'dismissed',
|
||||
review: { state: 'changes_requested' },
|
||||
}),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('never regresses or reopens a resolving Issue', () => {
|
||||
const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
|
||||
const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
|
||||
test('keeps ordinary pull request events as forward-only implementation signals', () => {
|
||||
for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
|
||||
assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
|
||||
}
|
||||
assert.equal(
|
||||
resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
assert.equal(nextResolvingIssueStatus('In progress', implementation), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', implementation), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', review), null)
|
||||
assert.equal(nextResolvingIssueStatus('Done', review), null)
|
||||
assert.equal(nextResolvingIssueStatus('No action', review), null)
|
||||
assert.equal(nextResolvingIssueStatus(null, review), null)
|
||||
test('toggles automation-owned work on request changes and repeated review request', () => {
|
||||
for (const status of ['Inbox', 'Backlog', 'Ready']) {
|
||||
assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
|
||||
assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
|
||||
}
|
||||
let status = nextResolvingIssueStatus(
|
||||
'In review',
|
||||
'changes-requested',
|
||||
'dsh-issue-management',
|
||||
)
|
||||
assert.equal(status, 'In progress')
|
||||
status = nextResolvingIssueStatus(status, 'review-requested')
|
||||
assert.equal(status, 'In review')
|
||||
})
|
||||
|
||||
test('preserves human review status and terminal Issues', () => {
|
||||
assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
|
||||
assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
|
||||
assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
|
||||
assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
|
||||
})
|
||||
|
||||
test('keeps lifecycle projection independent of PR metadata enforcement', () => {
|
||||
@@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () =>
|
||||
}
|
||||
|
||||
assert.ok(validatePullRequest(pull).length > 0)
|
||||
assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
|
||||
})
|
||||
|
||||
test('exempts Draft, Bot, and App PRs', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ concurrency:
|
||||
jobs:
|
||||
lifecycle:
|
||||
name: Issue lifecycle
|
||||
if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out trusted policy
|
||||
|
||||
@@ -100,7 +100,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
|
||||
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
||||
- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)).
|
||||
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
|
||||
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. A `SessionEventMap` member is required-on-read by default — builds that do not know its type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
|
||||
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
|
||||
- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
|
||||
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Running benchmarks
|
||||
|
||||
To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks.
|
||||
@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
await page.getByRole('button', { name: 'Copy' }).first().focus()
|
||||
|
||||
@@ -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/config-catalog.md
|
||||
config-catalog.md: 646198cea4d30ddc799ef4886af309f376cfa9f2
|
||||
config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c
|
||||
config-catalog.md: cc50a2021cfc378481dc3e830a0973773bae1e02
|
||||
config-catalog.zh.md: 5ed4f54a60130927ba710cae1f041da397c2d77a
|
||||
|
||||
@@ -1441,7 +1441,7 @@ export interface Config {
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
Source: [`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts)
|
||||
Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-projection-cache`
|
||||
|
||||
|
||||
@@ -1443,7 +1443,7 @@ export interface Config {
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
来源:[`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts)
|
||||
来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-projection-cache`
|
||||
|
||||
|
||||
@@ -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/event-producer-consumer.md
|
||||
event-producer-consumer.md: 4e68c0e5e6262ec7f31f4ed2d146ee18d3faa02a
|
||||
event-producer-consumer.zh.md: 1879e74f3e8c01143292b82a1745f4438f64e0ac
|
||||
event-producer-consumer.md: 33e3f8e67291d9f3b50d9a52fc3104e2e218d799
|
||||
event-producer-consumer.zh.md: 2f036ba0cad1d86952424c4d4969795a3862cfd7
|
||||
|
||||
@@ -30,17 +30,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`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), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`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), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../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:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../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:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`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), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`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), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../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:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../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:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
@@ -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/persistence-catalog.md
|
||||
persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb
|
||||
persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4
|
||||
persistence-catalog.md: 88d8f833ce3e6c51692db74519279a5354a1759b
|
||||
persistence-catalog.zh.md: 5ab0fa0c6ccb099ba10b9021625f20486a02d94c
|
||||
|
||||
+26
-15
@@ -7,7 +7,7 @@ Every event type that can appear in a session's durable event log: the complete
|
||||
|
||||
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).
|
||||
|
||||
The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
|
||||
The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
|
||||
|
||||
## Event envelope
|
||||
|
||||
@@ -63,6 +63,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
@@ -79,7 +90,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}[T]
|
||||
```
|
||||
|
||||
Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts)
|
||||
Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts)
|
||||
|
||||
## Events
|
||||
|
||||
@@ -192,7 +203,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter
|
||||
|
||||
Types: [StreamChunk](subsystems/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -208,7 +219,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/
|
||||
|
||||
Types: [TokenUsage](subsystems/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `command/*`
|
||||
|
||||
@@ -488,7 +499,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s
|
||||
'request/context': RequestContext
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `request/header` — log-only
|
||||
|
||||
@@ -500,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `sandbox/*`
|
||||
|
||||
@@ -553,7 +564,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
|
||||
'session/end-seed': Record<string, never>
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `session/title` — log-only
|
||||
|
||||
@@ -589,7 +600,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -598,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `subagent/*`
|
||||
|
||||
@@ -628,7 +639,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent
|
||||
|
||||
Types: [TodoItem](subsystems/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -645,7 +656,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/
|
||||
|
||||
Types: [CallId](subsystems/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
@@ -714,7 +725,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -734,7 +745,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/
|
||||
|
||||
Types: [TurnEndReason](subsystems/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -748,7 +759,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/
|
||||
'turn/start': { turn: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -765,7 +776,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/
|
||||
'user/message': UserMessage
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `web/*`
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。
|
||||
|
||||
以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
|
||||
以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`、可选的未知类型跳过标记 `ignorable`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
|
||||
|
||||
## 事件信封
|
||||
|
||||
@@ -65,6 +65,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
|
||||
@@ -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/subsystems/persistence.md
|
||||
persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2
|
||||
persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6
|
||||
persistence.md: 7deaa9b30b5a6b1e3cbdcc38255b3974b5abf477
|
||||
persistence.zh.md: c5afcf67319da408b739d41b2b7ad3eb434ffbad
|
||||
|
||||
@@ -87,6 +87,10 @@ interface SessionHeader {
|
||||
}
|
||||
```
|
||||
|
||||
## Format refusal — logs a build cannot faithfully read
|
||||
|
||||
A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
|
||||
|
||||
## `CreateSessionOptions` — seeding and metadata
|
||||
|
||||
Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume.
|
||||
@@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot
|
||||
|
||||
Types: [SessionEvent](session.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts)
|
||||
Source: [`packages/session/session-persistence/src/index.ts:74`](../../packages/session/session-persistence/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -87,6 +87,10 @@ interface SessionHeader {
|
||||
}
|
||||
```
|
||||
|
||||
## 格式拒绝:本构建无法可靠读取的日志
|
||||
|
||||
后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。
|
||||
|
||||
## `CreateSessionOptions`:seed 与元数据
|
||||
|
||||
通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。
|
||||
@@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot
|
||||
|
||||
Types: [SessionEvent](session.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts)
|
||||
Source: [`packages/session/session-persistence/src/index.ts:74`](../../packages/session/session-persistence/src/index.ts)
|
||||
<!-- END GENERATED cordis-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/subsystems/session.md
|
||||
session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df
|
||||
session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb
|
||||
session.md: 990b249cde9f02343f2c668aee5d7c000837df56
|
||||
session.zh.md: 39e8ff1e8831fd75c8929c93e263622bb5aa6ea4
|
||||
|
||||
@@ -215,6 +215,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
@@ -733,7 +744,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="session-events"></a>
|
||||
|
||||
@@ -762,7 +773,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessiondisposed--emit"></a>
|
||||
|
||||
@@ -785,7 +796,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessionevent--emit"></a>
|
||||
|
||||
@@ -810,7 +821,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessionflush--parallel"></a>
|
||||
|
||||
@@ -832,5 +843,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -217,6 +217,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
@@ -737,7 +748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="session-events"></a>
|
||||
|
||||
@@ -766,7 +777,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessiondisposed--emit"></a>
|
||||
|
||||
@@ -789,7 +800,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessionevent--emit"></a>
|
||||
|
||||
@@ -814,7 +825,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts)
|
||||
|
||||
<a id="sessionflush--parallel"></a>
|
||||
|
||||
@@ -836,5 +847,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts)
|
||||
<!-- END GENERATED cordis-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/subsystems/subagent.md
|
||||
subagent.md: 220ecf493b2d3a84cede34c1cf72dbf0dcd80353
|
||||
subagent.zh.md: b0a0df5a778ed758421225e524c87cf8ef213c0b
|
||||
subagent.md: 42359215b2e21028e13734c2a4cd2488f86a07af
|
||||
subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257
|
||||
|
||||
@@ -293,7 +293,12 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is
|
||||
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
|
||||
*/
|
||||
interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
/**
|
||||
* The child's final assistant output is the content of its last non-empty
|
||||
* assistant message. Empty-content messages, including usage-only messages,
|
||||
* are skipped. Without a non-empty message, the output is its accumulated
|
||||
* assistant text stream, or `[]` when the child produced neither.
|
||||
*/
|
||||
readonly output: ContentBlock[]
|
||||
/**
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
@@ -614,7 +619,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
|
||||
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagent-events"></a>
|
||||
|
||||
@@ -640,7 +645,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentprovider-added--emit"></a>
|
||||
|
||||
@@ -657,7 +662,7 @@ A provider became resolvable in the registry.
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentprovider-removed--emit"></a>
|
||||
|
||||
@@ -674,7 +679,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentstart--emit"></a>
|
||||
|
||||
@@ -698,5 +703,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -293,7 +293,12 @@ type SubagentDescendantListEntry = SubagentListEntry & {
|
||||
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
|
||||
*/
|
||||
interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
/**
|
||||
* The child's final assistant output is the content of its last non-empty
|
||||
* assistant message. Empty-content messages, including usage-only messages,
|
||||
* are skipped. Without a non-empty message, the output is its accumulated
|
||||
* assistant text stream, or `[]` when the child produced neither.
|
||||
*/
|
||||
readonly output: ContentBlock[]
|
||||
/**
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
@@ -616,7 +621,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
|
||||
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagent-events"></a>
|
||||
|
||||
@@ -642,7 +647,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentprovider-added--emit"></a>
|
||||
|
||||
@@ -659,7 +664,7 @@ A provider became resolvable in the registry.
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentprovider-removed--emit"></a>
|
||||
|
||||
@@ -676,7 +681,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="subagentstart--emit"></a>
|
||||
|
||||
@@ -700,5 +705,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -341,6 +341,10 @@ const SCENARIOS: Scenario[] = [
|
||||
// Windows bash process-tree kill is deferred with the Bash execution domain.
|
||||
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
|
||||
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
|
||||
// Keyless authored scenario: the child ends at max-tokens with an empty
|
||||
// usage-only assistant/message after earlier text and a tool call. The
|
||||
// parent's tool result must retain that assistant output and stop reason.
|
||||
{ name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false },
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "initialize"
|
||||
},
|
||||
{
|
||||
"op": "newSession"
|
||||
},
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":2,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1}
|
||||
{"type":"approval/policy","seq":0,"time":1786373921809,"data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"agent/inbox/spliced","seq":1,"time":1786373921809,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}}
|
||||
{"type":"turn/start","seq":2,"time":1786373921810,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":3,"time":1786373921810,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","seq":4,"time":1786373921831,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}}
|
||||
{"type":"step/start","seq":5,"time":1786373921836,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":6,"time":1786348800142,"data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":7,"time":1786373921837,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"885ea744-63dd-4198-95be-267b9db94a57"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":8,"time":1786373921837,"data":{"title":"Write the words 'partial one',","messageSeqs":[6],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":9,"time":1786348800142,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":10,"time":1786348800142,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":9}}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":17,"time":1786348800146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial one"},{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e4d07b2-6ce2-4ab6-8be0-fbdf2d3af138"},"usage":{"inputTokens":20,"outputTokens":9}},"sourceEventSeqs":[11,12,13,14,15,16],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":18,"time":1786348800146,"data":{"turn":1,"step":1,"callId":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":19,"time":1786348800150,"data":{"todos":[{"content":"keep going","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":20,"time":1786348800151,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_1"},"content":[{"type":"tool-result","toolCallId":"call_child_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"67efbbf3-ca1e-4d23-8f19-940cb391ff1e"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":21,"time":1786348800151,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":22,"time":1786348800156,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":24,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":25,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}}}
|
||||
{"type":"assistant/message","seq":27,"time":1786348800160,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb92e4ec-f260-4415-9782-b71147ea378d"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":28,"time":1786348800160,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":29,"time":1786348800160,"data":{"turn":1,"reason":{"kind":"max-tokens"}}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1786348800078,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1786348800079,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1786348800079,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1786348800114,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1786348800114,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1786348800114,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"32a8b2ce-f1f9-411b-940d-c80f772561ac"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1786348800114,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1786348800115,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1786348800115,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1786348800120,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f4269cd2-9132-4b68-8f9b-ff3a40321bc9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1786348800121,"data":{"turn":1,"step":1,"callId":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":1786348800163,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parent_1"},"content":[{"type":"tool-result","toolCallId":"call_parent_1","content":[{"type":"text","text":"Error: subagent run hit its token limit before finishing\nPartial output before the run ended:\npartial one"}],"isError":true}],"role":"user","id":"5dd34050-a533-4f1b-99ee-5fc62c6a4502"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":16,"time":1786348800163,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":17,"time":1786348800169,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":22,"time":1786348800173,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fb14560d-1d98-4b18-8736-b079de400315"},"usage":{"inputTokens":12,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":23,"time":1786348800173,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":24,"time":1786348800173,"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":"PARENT_DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Assembled-app regression for the session-format refusal surface: resuming a
|
||||
* log written by a "newer" harness (format version ahead, or an unknown
|
||||
* required event type) fails loud through the real Loader composition, and the
|
||||
* error the product user sees names the direction and the raw log path.
|
||||
* @module session-format-guard-snapshot
|
||||
*/
|
||||
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import SessionStore, {
|
||||
SESSION_FORMAT_VERSION,
|
||||
SessionId,
|
||||
type SessionEvent,
|
||||
type SessionHeader,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit')
|
||||
const replayFixture = join(fixtureDir, 'replay.jsonl')
|
||||
const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url))
|
||||
const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
// The resumed-agent fixture in the shared config resumes exactly this id.
|
||||
const sessionId = SessionId('workspace-context-resume')
|
||||
|
||||
/** Persist one session with the given header version and events, returning its log path. */
|
||||
async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd }
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta)
|
||||
await ctx.sessionPersistence.append(sessionId, events)
|
||||
const location = ctx.sessionPersistence.locate(meta)
|
||||
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
|
||||
return location.path
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function closedTurn(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
describe('session format guard through the assembled app', () => {
|
||||
it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => {
|
||||
let sessionPath = ''
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'newer-format resume refusal',
|
||||
tempDirPrefix: 'dsh-format-guard-version-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath,
|
||||
binArgs: [configPath, 'Try to resume.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
expectedExitCode: 1,
|
||||
prepare: async (runCwd) => {
|
||||
sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
|
||||
},
|
||||
})
|
||||
expect(result.stderr).toContain(
|
||||
`session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`,
|
||||
)
|
||||
// macOS reports the temp dir via the /private symlink parent; assert the
|
||||
// stable path suffix instead of the realpath-dependent prefix.
|
||||
expect(result.stderr).toContain('(raw log: ')
|
||||
expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('refuses to resume a log with an unknown required event type', async () => {
|
||||
let sessionPath = ''
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'unknown-event resume refusal',
|
||||
tempDirPrefix: 'dsh-format-guard-event-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath,
|
||||
binArgs: [configPath, 'Try to resume.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
expectedExitCode: 1,
|
||||
prepare: async (runCwd) => {
|
||||
sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [
|
||||
...closedTurn(),
|
||||
{ type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent,
|
||||
])
|
||||
},
|
||||
})
|
||||
expect(result.stderr).toContain(
|
||||
`session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`,
|
||||
)
|
||||
// macOS reports the temp dir via the /private symlink parent; assert the
|
||||
// stable path suffix instead of the realpath-dependent prefix.
|
||||
expect(result.stderr).toContain('(raw log: ')
|
||||
expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -6,6 +6,8 @@ pre-commit:
|
||||
jobs:
|
||||
- name: translation pairing (staged records)
|
||||
glob: '*.i18n.yaml'
|
||||
exclude:
|
||||
- '.agents/notes/archived/**'
|
||||
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
|
||||
|
||||
- name: lint (staged)
|
||||
@@ -35,6 +37,8 @@ pre-merge-commit:
|
||||
jobs:
|
||||
- name: translation pairing (staged records)
|
||||
glob: '*.i18n.yaml'
|
||||
exclude:
|
||||
- '.agents/notes/archived/**'
|
||||
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
|
||||
|
||||
pre-push:
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735
|
||||
README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d
|
||||
README.md: eb7df95bde10dafd7afcb168d30c9dda90296687
|
||||
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
|
||||
|
||||
@@ -52,7 +52,6 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe
|
||||
README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a
|
||||
README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f
|
||||
README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87
|
||||
|
||||
@@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for
|
||||
|
||||
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
Every `SessionEvent` carries three optional top-level fields (structural metadata):
|
||||
|
||||
- `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present.
|
||||
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
|
||||
- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
@@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
|
||||
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
|
||||
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
|
||||
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
|
||||
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
|
||||
|
||||
@@ -76,10 +76,11 @@
|
||||
|
||||
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
|
||||
|
||||
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
|
||||
每个 `SessionEvent` 都有三个可选顶层字段(结构元数据):
|
||||
|
||||
- `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。
|
||||
- `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。
|
||||
- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。
|
||||
|
||||
### 元数据类型(`types.ts`)
|
||||
|
||||
@@ -139,5 +140,5 @@
|
||||
|
||||
- **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
|
||||
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
|
||||
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
|
||||
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
|
||||
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
|
||||
|
||||
@@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed turn that entered at least one model step, ignoring
|
||||
@@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
case 'data':
|
||||
case 'surfaceOp':
|
||||
case 'sourceEventSeqs':
|
||||
case 'ignorable':
|
||||
break
|
||||
default:
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
@@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
if (typeof type !== 'string'
|
||||
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|
||||
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|
||||
|| event['data'] === undefined) {
|
||||
|| event['data'] === undefined
|
||||
|| (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
switch (type) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
|
||||
* `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
|
||||
* `pnpm run verify-persistence-catalog`, part of `doc-sync`).
|
||||
* @module @deepseek-ai/dsh-session/known-event-types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every `SessionEventMap` member declared in this repository — the event
|
||||
* vocabulary this build understands. The persistence read path refuses to
|
||||
* interpret a log containing a type outside this set unless the event
|
||||
* carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
|
||||
* in `./types.ts`): such a log was likely written by a newer harness, and
|
||||
* silently skipping a required event would reconstruct a wrong session.
|
||||
* Downstream (out-of-repo) plugin events are outside this list by
|
||||
* construction; a registration surface for them is deferred until such a
|
||||
* consumer exists.
|
||||
*/
|
||||
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'agent-preset/selected',
|
||||
'agent/inbox/spliced',
|
||||
'approval/asked',
|
||||
'approval/decided',
|
||||
'approval/policy',
|
||||
'assistant/chunk',
|
||||
'assistant/message',
|
||||
'command/done',
|
||||
'command/run',
|
||||
'compact/end',
|
||||
'compact/prune',
|
||||
'compact/start',
|
||||
'compact/summary',
|
||||
'feedback/record',
|
||||
'goal/change',
|
||||
'hook/invoked',
|
||||
'hook/result',
|
||||
'llm/retry',
|
||||
'llm/retry-started',
|
||||
'permission/preset',
|
||||
'plan/mode',
|
||||
'request/context',
|
||||
'request/header',
|
||||
'sandbox/mode',
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
'session/title-llm-request',
|
||||
'step/end',
|
||||
'step/start',
|
||||
'subagent/descriptor',
|
||||
'todo/write',
|
||||
'tool/call',
|
||||
'tool/code-dispatch',
|
||||
'tool/code-dispatch-start',
|
||||
'tool/result',
|
||||
'turn/end',
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'web/deepseek-search-llm-request',
|
||||
])
|
||||
@@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId {
|
||||
* and enforced by every persistence backend on load. The single source of truth for the
|
||||
* version — write sites and the load-time check all read it.
|
||||
* While the harness is unreleased it is pinned at `0`: no compatibility is
|
||||
* implied, incompatible logs are rejected, and no migration is provided. A
|
||||
* monotonic version policy starts with the first tagged release.
|
||||
* implied, incompatible logs are rejected, and no migration is provided.
|
||||
*
|
||||
* The version is a single monotonic integer with no major/minor split. Whether
|
||||
* a bump is needed is decided by what the WRITER emits, never by what a newer
|
||||
* reader can accept: bump exactly when an older runtime could no longer handle
|
||||
* a new log with full semantic correctness ("parses without error" is not
|
||||
* correctness — silently skipping content that shapes reconstruction is a
|
||||
* wrong read). Only structural changes reach that bar: the header shape, the
|
||||
* {@link SessionEvent} envelope, core event semantics, or the surface
|
||||
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
|
||||
* Adding an ordinary event type does not bump — the per-event
|
||||
* {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
|
||||
* in doubt, bump: a near-identity upgrade step is almost free, a missed bump
|
||||
* makes older runtimes read new logs wrong silently. The full mechanism
|
||||
* (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
|
||||
* recorded in the session-log-version-mechanism Agent Note
|
||||
* (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
@@ -389,6 +404,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
|
||||
@@ -1090,12 +1090,20 @@ describe('Session', () => {
|
||||
{ ...base, time: '1' },
|
||||
{ ...base, time: 0.5 },
|
||||
{ type: base.type, seq: base.seq, time: base.time },
|
||||
{ ...base, ignorable: false },
|
||||
{ ...base, ignorable: 'yes' },
|
||||
]
|
||||
|
||||
for (const [index, event] of cases.entries()) {
|
||||
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
.toThrow(/invalid event envelope/)
|
||||
}
|
||||
|
||||
// `ignorable: true` is the one accepted marker value (unknown-type skip contract).
|
||||
const marked = Session.create(SessionId('ignorable-envelope'), [
|
||||
{ ...base, ignorable: true } as SessionEvent,
|
||||
])
|
||||
expect(marked.events[0]?.ignorable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# AGENTS.md — Experimental and internal packages
|
||||
|
||||
These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale.
|
||||
|
||||
- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group.
|
||||
- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph.
|
||||
- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles.
|
||||
- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise.
|
||||
- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements.
|
||||
- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies.
|
||||
- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/README.md
|
||||
README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1
|
||||
README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18
|
||||
@@ -1,7 +0,0 @@
|
||||
# experimental/ — experimental and internal packages
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release.
|
||||
|
||||
No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries.
|
||||
@@ -1,7 +0,0 @@
|
||||
# experimental/:实验性与内部专用包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。
|
||||
|
||||
该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。
|
||||
@@ -45,6 +45,7 @@ export const sessionEventSchema = z.object({
|
||||
data: z.unknown(),
|
||||
sourceEventSeqs: z.array(z.number()).optional(),
|
||||
surfaceOp: z.unknown().optional(),
|
||||
ignorable: z.literal(true).optional(),
|
||||
}) as unknown as z.ZodType<SessionEvent>
|
||||
|
||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
|
||||
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
|
||||
* string (wire-validation probes).
|
||||
* - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
|
||||
* assistant/message for a usage-only max-tokens step.
|
||||
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
|
||||
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
|
||||
* arrives, then poll for the GO file before answering (deterministic
|
||||
@@ -117,7 +119,9 @@ function runTurn(sessionId: string): void {
|
||||
message: {
|
||||
id: `fake-assistant-${seq}`,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
// Model the usage-only message recorded after a max-tokens step that
|
||||
// assembled no output blocks.
|
||||
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/protocol/README.md
|
||||
README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f
|
||||
README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1
|
||||
README.md: 082a890454f900aec51df123669f28814d39d601
|
||||
README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430
|
||||
|
||||
@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
|
||||
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface SubagentFinishedNotification {
|
||||
status: SdkRunStatus
|
||||
/** The provider-reported stop reason. */
|
||||
stopReason: SubagentStopReason
|
||||
/** The child's final assistant message, when it produced one. */
|
||||
/** The child's selected assistant output; absent when the child produced none. */
|
||||
lastAssistantMessage?: ContentBlock[]
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', (
|
||||
})
|
||||
|
||||
expect(stderr).not.toContain('listener threw')
|
||||
// A result without output omits lastAssistantMessage from the wire; it
|
||||
// never sends `[]`.
|
||||
expect(JSON.parse(stdout) as unknown).toEqual([{
|
||||
method: 'subagent.finished',
|
||||
params: {
|
||||
@@ -115,7 +117,6 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', (
|
||||
childSessionId: 'built-child',
|
||||
status: 'ok',
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -736,6 +736,8 @@ describe('HarnessSdkServer', () => {
|
||||
stopReason: 'error',
|
||||
})
|
||||
|
||||
// A result without output omits lastAssistantMessage from the wire; it
|
||||
// never sends `[]`.
|
||||
expect(transport.notifications).toContainEqual({
|
||||
method: 'subagent.finished',
|
||||
params: {
|
||||
@@ -745,7 +747,6 @@ describe('HarnessSdkServer', () => {
|
||||
childSessionId: 'fallback-child-session',
|
||||
status: 'ok',
|
||||
stopReason: 'max-tokens',
|
||||
lastAssistantMessage: [],
|
||||
},
|
||||
})
|
||||
expect(transport.notifications).toContainEqual({
|
||||
|
||||
@@ -2633,7 +2633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEvent',
|
||||
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
|
||||
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
||||
import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
|
||||
import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** Physical encoding selected for JSONL session artifacts. */
|
||||
export type JsonlCompression = 'zstd' | 'none'
|
||||
@@ -229,6 +230,22 @@ interface SessionLogScan {
|
||||
}
|
||||
|
||||
/** Parse one complete header record supplied independently from event rows. */
|
||||
/**
|
||||
* Refuse a header carrying a format version this build does not read BEFORE
|
||||
* validating the current header shape or decoding any event row: a future
|
||||
* format need not satisfy today's structural checks at all, and its user must
|
||||
* see "upgrade the harness", never "corrupt session log".
|
||||
* @param parsed - the JSON-parsed first line of a session artifact.
|
||||
*/
|
||||
function refuseForeignFormatVersion(parsed: unknown): void {
|
||||
if (typeof parsed !== 'object' || parsed === null) return
|
||||
const { version, id } = parsed as { version?: unknown; id?: unknown }
|
||||
if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return
|
||||
throw new SessionFormatUnsupportedError(
|
||||
sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version),
|
||||
)
|
||||
}
|
||||
|
||||
function parseHeaderRecord(record: Buffer): SessionHeader {
|
||||
if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) {
|
||||
throw new Error('empty or header-less session log')
|
||||
@@ -239,6 +256,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader {
|
||||
} catch {
|
||||
throw new Error('corrupt session log: header line is not valid JSON')
|
||||
}
|
||||
refuseForeignFormatVersion(parsed)
|
||||
if (!isHeaderLine(parsed)) {
|
||||
throw new Error('corrupt session log: first line is not a session header')
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { scheduler } from 'node:timers/promises'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -256,19 +256,29 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'>
|
||||
if (this.compression === 'zstd') {
|
||||
prefix = await this.readZstdPrefix(buffer, signal)
|
||||
} else {
|
||||
signal?.throwIfAborted()
|
||||
const { meta, events, committedBytes } = scanLog(buffer)
|
||||
signal?.throwIfAborted()
|
||||
prefix = {
|
||||
meta,
|
||||
events,
|
||||
...committedBytes < buffer.byteLength
|
||||
? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
|
||||
: {},
|
||||
try {
|
||||
if (this.compression === 'zstd') {
|
||||
prefix = await this.readZstdPrefix(buffer, signal)
|
||||
} else {
|
||||
signal?.throwIfAborted()
|
||||
const { meta, events, committedBytes } = scanLog(buffer)
|
||||
signal?.throwIfAborted()
|
||||
prefix = {
|
||||
meta,
|
||||
events,
|
||||
...committedBytes < buffer.byteLength
|
||||
? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// A parse-time format refusal predates any SessionHeader, so the
|
||||
// coordinator's locate-based enrichment cannot run; attach the artifact
|
||||
// this read actually refused.
|
||||
if (error instanceof SessionFormatUnsupportedError && error.location === undefined) {
|
||||
throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
@@ -186,6 +186,76 @@ describe('SessionPersistenceJsonl: format helpers', () => {
|
||||
})
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a structurally foreign future header as unsupported, not corrupt', async () => {
|
||||
const absoluteRoot = await freshRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
|
||||
// A future format need not satisfy today's header shape at all (no
|
||||
// createdAt, unknown fields): the version must be refused before shape
|
||||
// validation, so the user sees the upgrade direction.
|
||||
const id = SessionId('future-shape')
|
||||
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`)
|
||||
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
|
||||
expect(failure?.message).toContain(`(raw log: ${path})`)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps a non-object header line a corruption, not a format refusal', async () => {
|
||||
const absoluteRoot = await freshRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
|
||||
// Valid JSON that is no object carries no version to compare, so the
|
||||
// version guard must pass it through to the corruption diagnostics.
|
||||
const id = SessionId('scalar-header')
|
||||
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, '42\n')
|
||||
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).not.toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toContain('first line is not a session header')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('names a foreign-version header by its stringified non-string id', async () => {
|
||||
const absoluteRoot = await freshRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
|
||||
// A future header's id field is as untrusted as the rest of its shape:
|
||||
// the refusal must still name the session it read, not crash on the type.
|
||||
const id = SessionId('numeric-id')
|
||||
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`)
|
||||
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toContain('session "123" uses log format v42')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('points a format refusal at the raw log path', async () => {
|
||||
const absoluteRoot = await freshRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
|
||||
const m = { ...meta('newer-format', '/work'), version: 7 }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`)
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
|
||||
@@ -28,15 +28,18 @@ import {
|
||||
export { SCHEMA_VERSION } from './schema.ts'
|
||||
|
||||
/**
|
||||
* Serialize an event's surface-metadata fields for SQL binding. Both fields are
|
||||
* nullable TEXT columns — null when the event has no surface metadata (non-surface
|
||||
* events, events written before surface support).
|
||||
* Serialize an event's optional envelope fields for SQL binding. The surface
|
||||
* fields are nullable TEXT columns — null when the event has no surface
|
||||
* metadata (non-surface events, events written before surface support); the
|
||||
* ignorable marker is a nullable INTEGER column — `1` iff the envelope carries
|
||||
* `ignorable: true`.
|
||||
*/
|
||||
function surfaceBindings(event: SessionEvent): [string | null, string | null] {
|
||||
function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] {
|
||||
const se = event as SessionEvent<SurfaceEventType>
|
||||
return [
|
||||
se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null,
|
||||
se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
|
||||
event.ignorable === true ? 1 : null,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -225,7 +228,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
if (row === undefined) return undefined
|
||||
const meta = rowToMeta(row)
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
|
||||
.all(id, fromSeq) as unknown as EventRow[]
|
||||
signal?.throwIfAborted()
|
||||
const { preserved } = scanRows(eventRows, fromSeq)
|
||||
@@ -247,7 +250,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
const row = this.rowFor(id)
|
||||
if (row !== undefined) {
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(id) as unknown as EventRow[]
|
||||
snapshot = { row, eventRows }
|
||||
}
|
||||
@@ -279,14 +282,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
|
||||
await this.ready
|
||||
const insertEvent = this.db.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
this.db.exec('BEGIN')
|
||||
try {
|
||||
if (!isMaterialized) this.writeRow(meta)
|
||||
for (const event of events) {
|
||||
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
|
||||
const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
|
||||
}
|
||||
this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
|
||||
this.db.exec('COMMIT')
|
||||
@@ -310,11 +313,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}
|
||||
if (closers.length > 0) {
|
||||
const insertEvent = this.db.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
for (const event of closers) {
|
||||
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
|
||||
const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
|
||||
}
|
||||
}
|
||||
if (tornMarker !== undefined || closers.length > 0) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 14
|
||||
export const SCHEMA_VERSION = 15
|
||||
|
||||
/** SQLite application id protecting unrelated databases from persistence writes. */
|
||||
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
|
||||
@@ -55,6 +55,8 @@ export interface EventRow {
|
||||
source_event_seqs: string | null
|
||||
/** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
|
||||
surface_op: string | null
|
||||
/** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */
|
||||
ignorable: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +141,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
data TEXT NOT NULL,
|
||||
source_event_seqs TEXT,
|
||||
surface_op TEXT,
|
||||
ignorable INTEGER,
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT
|
||||
`)
|
||||
@@ -203,12 +206,14 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
||||
...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
|
||||
...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
|
||||
}
|
||||
const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {}
|
||||
return {
|
||||
type: row.type as SessionEvent['type'],
|
||||
seq: row.seq,
|
||||
time: row.time,
|
||||
data: JSON.parse(row.data) as SessionEvent['data'],
|
||||
...surfaceFields,
|
||||
...ignorableField,
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('scanRows', () => {
|
||||
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
|
||||
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
|
||||
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
|
||||
ignorable: e.ignorable === true ? 1 : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,8 +143,8 @@ describe('scanRows', () => {
|
||||
|
||||
it('throws on an unparsable row inside the committed region', () => {
|
||||
const withCorruptCommitted: EventRow[] = [
|
||||
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
|
||||
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
|
||||
]
|
||||
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
|
||||
})
|
||||
@@ -151,7 +152,7 @@ describe('scanRows', () => {
|
||||
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
|
||||
const withCorruptTail: EventRow[] = [
|
||||
...rows(oneTurnLog()),
|
||||
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
|
||||
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after
|
||||
]
|
||||
const { preserved, tornFrom } = scanRows(withCorruptTail)
|
||||
expect(preserved).toEqual(oneTurnLog())
|
||||
@@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(14)
|
||||
expect(SCHEMA_VERSION).toBe(15)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
@@ -857,6 +858,7 @@ describe('surface field round-trip', () => {
|
||||
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
||||
source_event_seqs: JSON.stringify([3, 5]),
|
||||
surface_op: JSON.stringify('append'),
|
||||
ignorable: null,
|
||||
}
|
||||
const event = rowToEvent(row)
|
||||
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
|
||||
@@ -869,6 +871,7 @@ describe('surface field round-trip', () => {
|
||||
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
||||
source_event_seqs: JSON.stringify([0, 1]),
|
||||
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
|
||||
ignorable: null,
|
||||
}
|
||||
const event = rowToEvent(row)
|
||||
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
|
||||
@@ -879,10 +882,10 @@ describe('surface field round-trip', () => {
|
||||
const rows: EventRow[] = [
|
||||
{ seq: 0, type: 'user/message', time: 1,
|
||||
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
|
||||
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
|
||||
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
|
||||
{ seq: 1, type: 'turn/end', time: 2,
|
||||
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
|
||||
source_event_seqs: null, surface_op: null },
|
||||
source_event_seqs: null, surface_op: null, ignorable: 1 },
|
||||
]
|
||||
const { preserved } = scanRows(rows)
|
||||
expect(preserved).toHaveLength(2)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
|
||||
README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2
|
||||
README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7
|
||||
README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82
|
||||
README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70
|
||||
|
||||
@@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
|
||||
| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
|
||||
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
|
||||
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
adoptSessionEvent,
|
||||
interruptedTurnClosers,
|
||||
KNOWN_SESSION_EVENT_TYPES,
|
||||
SESSION_FORMAT_VERSION,
|
||||
SessionPreparation,
|
||||
snapshotJsonValue,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionInspection } from './index.ts'
|
||||
import type { SessionInspection, SessionLocation } from './index.ts'
|
||||
import type { SessionPersistenceRevision } from './revision.ts'
|
||||
import { observeQueuedAbort, SessionPreparations } from './preparations.ts'
|
||||
import type { SessionPreparationReservation } from './preparations.ts'
|
||||
@@ -43,6 +44,42 @@ export class SessionPersistenceCorruptionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored log is intact but this runtime cannot faithfully interpret it:
|
||||
* the header carries an unsupported format version, or an event's type is
|
||||
* unknown to this build and the event is not marked ignorable. Distinct from
|
||||
* {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log
|
||||
* remains readable at {@link location} when the backend keeps one artifact
|
||||
* per session.
|
||||
*/
|
||||
export class SessionFormatUnsupportedError extends Error {
|
||||
/**
|
||||
* @param message - stable reason the log cannot be interpreted, already
|
||||
* including the raw-log path when one exists.
|
||||
* @param location - the backend's artifact location, when one exists.
|
||||
*/
|
||||
constructor(message: string, readonly location?: SessionLocation) {
|
||||
super(message)
|
||||
this.name = 'SessionFormatUnsupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction-aware refusal text for a stored session whose format version this
|
||||
* build does not read. Shared by the coordinator's load-time check and by
|
||||
* backends that must refuse BEFORE decoding version-dependent structure (a
|
||||
* future format may not satisfy today's structural checks at all, and the
|
||||
* user must see "upgrade the harness", never "corrupt").
|
||||
* @param id - the stored session id, for message context.
|
||||
* @param version - the stored format version.
|
||||
* @returns the stable refusal text, without a raw-log path suffix.
|
||||
*/
|
||||
export function sessionFormatVersionRefusal(id: string, version: number): string {
|
||||
return version > SESSION_FORMAT_VERSION
|
||||
? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`
|
||||
: `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`
|
||||
}
|
||||
|
||||
/** Coordinator policy supplied by a concrete persistence backend. */
|
||||
export interface PersistenceCoordinatorOptions {
|
||||
/** Maximum completed unpublished preparations retained for reuse. */
|
||||
@@ -126,6 +163,11 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
* contains a supported legacy shape whose normalization needs earlier
|
||||
* message-identity facts, in which case the coordinator falls back
|
||||
* to the complete stored prefix.
|
||||
* Unknown-type refusal follows the same suffix scope: a seek-capable
|
||||
* backend's `readFrom` checks only the returned suffix, while the
|
||||
* sequential fallback parses the whole artifact and refuses on an unknown
|
||||
* required event anywhere in it — over-refusal on the sequential side is
|
||||
* accepted rather than widening the seek read.
|
||||
* @param id - persisted session id to resolve.
|
||||
* @param fromSeq - first event seq to include (non-negative safe integer,
|
||||
* validated by the coordinator before this hook runs).
|
||||
@@ -156,6 +198,14 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
*/
|
||||
list(signal?: AbortSignal): Promise<SessionHeader[]>
|
||||
|
||||
/**
|
||||
* Optional side-effect-free artifact locator, used to point refusal
|
||||
* diagnostics ({@link SessionFormatUnsupportedError}) at the raw log.
|
||||
* Backends without one artifact per session omit it or return `undefined`.
|
||||
* @param meta - the header whose artifact is requested.
|
||||
*/
|
||||
locate?(meta: SessionHeader): SessionLocation | undefined
|
||||
|
||||
/**
|
||||
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
|
||||
* coordinator's dispose effect AFTER the quiescence drain. A stateless file
|
||||
@@ -631,9 +681,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
|
||||
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
|
||||
// Every append route converges here: the public service, live write-behind
|
||||
// drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that
|
||||
// shared boundary so a stale JavaScript plugin cannot persist an event that
|
||||
// this same backend will refuse to load.
|
||||
// drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at
|
||||
// this shared boundary so a stale JavaScript plugin cannot persist a
|
||||
// retired shape this backend refuses to load. The unknown-type guard is
|
||||
// deliberately read-side only: an append-time refusal would stall a live
|
||||
// session's durability mid-flight, which costs more than a loud refusal at
|
||||
// the log's next load (trade-off owned by the session-log-version-mechanism
|
||||
// Agent Note).
|
||||
assertSupportedEvents(events, id)
|
||||
if (events.length === 0) return
|
||||
this.preparations.assertWritable(id)
|
||||
@@ -806,7 +860,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
const whole = await this.readStoredPrefix(id, signal)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
}
|
||||
return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) }
|
||||
const events = snapshotStoredEvents(suffix.events, id)
|
||||
this.assertEventsSupported(suffix.meta, events)
|
||||
return { meta: structuredClone(suffix.meta), events }
|
||||
}
|
||||
const whole = await this.readStoredPrefix(id, signal)
|
||||
// Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
|
||||
@@ -824,9 +880,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (stored === undefined) throw new Error(`session "${id}" not found`)
|
||||
this.assertStoredId(id, stored.meta)
|
||||
this.assertVersion(stored.meta)
|
||||
const events = snapshotStoredEvents(stored.events, id)
|
||||
this.assertEventsSupported(stored.meta, events)
|
||||
return {
|
||||
meta: structuredClone(stored.meta),
|
||||
events: snapshotStoredEvents(stored.events, id),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,6 +897,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
this.assertStoredId(id, meta)
|
||||
this.assertVersion(meta)
|
||||
const storedEvents = adoptStoredEvents(events, id)
|
||||
this.assertEventsSupported(meta, storedEvents)
|
||||
|
||||
// Preserve complete interrupted events and synthesize only missing closers.
|
||||
const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
|
||||
@@ -861,6 +920,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
closers,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// An unsupported format is a refusal over an intact log, not damage —
|
||||
// surface it unwrapped so callers can point at the raw artifact.
|
||||
if (error instanceof SessionFormatUnsupportedError) throw error
|
||||
throw new SessionPersistenceCorruptionError(
|
||||
`stored session "${id}" failed validation: ${String(error)}`,
|
||||
{ cause: error },
|
||||
@@ -982,11 +1044,36 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
|
||||
private assertVersion(meta: SessionHeader): void {
|
||||
if (meta.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`)
|
||||
if (meta.version === SESSION_FORMAT_VERSION) return
|
||||
throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version))
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a log containing an event type this build does not know, unless the
|
||||
* writer marked the event ignorable: an unrecognized required event may
|
||||
* change how the rest of the log must be interpreted, so silently skipping
|
||||
* it would reconstruct a wrong session (the envelope contract on
|
||||
* `SessionEvent.ignorable`). Runs on NORMALIZED events — after
|
||||
* `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes
|
||||
* this build still reads and rejected the ones it does not, so those keep
|
||||
* their specific diagnostics.
|
||||
*/
|
||||
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
|
||||
for (const event of events) {
|
||||
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
|
||||
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a format refusal that points at the raw artifact when the backend has one. */
|
||||
private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError {
|
||||
const location = this.backend.locate?.(meta)
|
||||
return new SessionFormatUnsupportedError(
|
||||
location === undefined ? reason : `${reason} (raw log: ${location.path})`,
|
||||
location,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reject backend metadata that is not bound to the requested session id. */
|
||||
private assertStoredId(id: SessionId, meta: SessionHeader): void {
|
||||
if (meta.id !== id) {
|
||||
@@ -1219,6 +1306,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
this.assertVersion(meta)
|
||||
const storedEvents = snapshotStoredEvents(events, session.header.id)
|
||||
this.assertEventsSupported(meta, storedEvents)
|
||||
if (!seedCoversPrefix(seed, storedEvents)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export {
|
||||
DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
|
||||
MAX_WRITE_BATCH_DELAY_MS,
|
||||
PersistenceCoordinator,
|
||||
SessionFormatUnsupportedError,
|
||||
SessionPersistenceCorruptionError,
|
||||
sessionFormatVersionRefusal,
|
||||
} from './coordinator.ts'
|
||||
export type {
|
||||
PersistenceBackend,
|
||||
|
||||
@@ -706,6 +706,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
.rejects.toThrow('lacks an identified message')
|
||||
}
|
||||
|
||||
// An out-of-repo event type passes only with the envelope's ignorable
|
||||
// marker (unknown-type refusal otherwise), and its non-object data is
|
||||
// not message-validated.
|
||||
const pluginId = SessionId('non-object-plugin-event')
|
||||
await ctx.sessionPersistence.create(meta(pluginId, WORK))
|
||||
await ctx.sessionPersistence.append(pluginId, [{
|
||||
@@ -713,11 +716,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: null,
|
||||
ignorable: true,
|
||||
} as unknown as SessionEvent])
|
||||
await expect(ctx.sessionPersistence.inspect(pluginId))
|
||||
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] })
|
||||
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
|
||||
await expect(ctx.sessionPersistence.readFrom(pluginId, 0))
|
||||
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] })
|
||||
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
|
||||
|
||||
for (const type of ['user/message', 'assistant/message'] as const) {
|
||||
const missingContentId = SessionId(`invalid-${type}-without-content`)
|
||||
@@ -1321,14 +1325,60 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unknown format version on load (assertVersion)', async () => {
|
||||
it('rejects a newer format version on load, naming the upgrade direction', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/)
|
||||
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an older format version on load without claiming an upgrade path', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unknown event type on load unless the event is marked ignorable', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const required = meta('unknown-required', WORK)
|
||||
await ctx.sessionPersistence.create(required)
|
||||
await ctx.sessionPersistence.append(required.id, [
|
||||
...oneTurnLog(),
|
||||
{ type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent,
|
||||
])
|
||||
const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
|
||||
expect(failure?.name).toBe('SessionFormatUnsupportedError')
|
||||
expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/)
|
||||
|
||||
const skippable = meta('unknown-ignorable', WORK)
|
||||
await ctx.sessionPersistence.create(skippable)
|
||||
await ctx.sessionPersistence.append(skippable.id, [
|
||||
...oneTurnLog(),
|
||||
{ type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent,
|
||||
])
|
||||
const loaded = await ctx.sessionPersistence.load(skippable.id)
|
||||
expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
@@ -232,8 +233,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
let processDisposal: Promise<void> | undefined
|
||||
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
|
||||
|
||||
// Accumulate the child's streamed assistant text — the SubagentResult output.
|
||||
const output: string[] = []
|
||||
// ACP exposes no complete assistant messages, so the shared fold selects its
|
||||
// accumulated assistant text.
|
||||
const fold = new AssistantOutputFold()
|
||||
// Shared mutable state keeps cancellation visible across async closures.
|
||||
const flags = { cancelled: false }
|
||||
|
||||
@@ -241,7 +243,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
output.push(acpContentText(update.content))
|
||||
fold.pushText(acpContentText(update.content))
|
||||
}
|
||||
// Other updates (thoughts, tool calls, plans) are consumed but not
|
||||
// surfaced — the subagent returns only its final answer.
|
||||
@@ -284,13 +286,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// The accumulated child text as harness ContentBlocks (empty array when the
|
||||
// child streamed nothing). Read at every return so a partial answer survives
|
||||
// a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
const text = output.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
// Read at every return so a partial answer survives a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
|
||||
|
||||
// Establish the remote session before publishing a handle. Any failure owns
|
||||
// the still-private process and therefore reaps it before rejecting.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md
|
||||
README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050
|
||||
README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a
|
||||
README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368
|
||||
README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024
|
||||
|
||||
@@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
|
||||
|
||||
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
|
||||
|
||||
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
|
||||
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error.
|
||||
|
||||
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
|
||||
|
||||
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
|
||||
|
||||
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。
|
||||
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。
|
||||
|
||||
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
|
||||
import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
|
||||
@@ -163,24 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
|
||||
}
|
||||
|
||||
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
|
||||
// The child's final answer: the last complete assistant message when one
|
||||
// exists, else the text streamed so far (a partial answer surviving cancel).
|
||||
let lastMessage: ContentBlock[] | undefined
|
||||
const partial: string[] = []
|
||||
// The child's final answer under the seam's canonical selection rule
|
||||
// (`AssistantOutputFold`); a partial answer survives cancel and error paths.
|
||||
const fold = new AssistantOutputFold()
|
||||
const observe = (notification: HarnessNotification): void => {
|
||||
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
|
||||
const event = notification.params.event as SessionEvent
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
partial.push(event.data.chunk.text)
|
||||
} else if (event.type === 'assistant/message') {
|
||||
lastMessage = event.data.message.content
|
||||
}
|
||||
}
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
if (lastMessage !== undefined) return lastMessage
|
||||
const text = partial.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
fold.push(notification.params.event as SessionEvent)
|
||||
}
|
||||
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
|
||||
|
||||
// Race the child turn against local cancellation; the shared settlement
|
||||
// flattens failures under the seam's never-reject contract.
|
||||
|
||||
@@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps streamed text when the terminal message is an empty usage-only step', async () => {
|
||||
// The child streams its answer, then emits an empty-content
|
||||
// assistant/message (the harness loop appends one to host usage on a
|
||||
// max-tokens step that assembled no text blocks). The empty message is
|
||||
// not assistant output and must not erase the streamed answer.
|
||||
const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' })
|
||||
const run = await ctx.subagents.start('dsh-sdk', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
expect(text(result.output)).toBe('hello from fake runtime')
|
||||
await run.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reports a settled-without-turn child as an error', async () => {
|
||||
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
|
||||
const run = await ctx.subagents.start('dsh-sdk', request())
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
|
||||
README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
|
||||
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14
|
||||
README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef
|
||||
README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b
|
||||
|
||||
@@ -14,7 +14,7 @@ The driver follows this sequence:
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed.
|
||||
5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user