diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index d3d1795489..017fd32b27 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -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", diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml index b8e885d109..a7df92883f 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md index dd567707bc..56004a39ce 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md @@ -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) diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md index f7fee58d6a..ee85319842 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -1,6 +1,7 @@ # Agent Note: PR 到 Issue 的状态仅向前投射 Status: implemented +Archived: 2026-08-10 [English](2026-08-04-forward-only-pr-issue-status.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml index a82d54640c..4c3a8c8db5 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md index 8a2d48ee23..4449279689 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -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) diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md index 004739ff47..6e00e2a936 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由评审驱动的 Issue 生命周期触发器 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md deleted file mode 100644 index 1ebae5dbb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +++ /dev/null @@ -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//` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-`. - -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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md deleted file mode 100644 index 2d09451c50..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ /dev/null @@ -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//`。包名仍为 `@deepseek-ai/dsh-`。 - -该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 - -官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。 - -实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。 - -尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 - -## 考虑过的替代方案 - -**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 - -**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 - -**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 - -## 后果 - -该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index c50bf478ad..f5c1e4cad3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 3c37bce05b..30fc45a122 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index 68903ff1fa..a3a9a11ec7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml similarity index 54% rename from .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 43e4b3d605..ee249b18ea 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md new file mode 100644 index 0000000000..25eb1230a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md new file mode 100644 index 0000000000..c47670f2de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -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 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 +- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml new file mode 100644 index 0000000000..612916a290 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md new file mode 100644 index 0000000000..693013f681 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md new file mode 100644 index 0000000000..64d61af21f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -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 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml new file mode 100644 index 0000000000..08607d5317 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md new file mode 100644 index 0000000000..9db9c64fc8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md new file mode 100644 index 0000000000..381a3f64a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -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 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml new file mode 100644 index 0000000000..945a68054c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md new file mode 100644 index 0000000000..e5e81e3e37 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md @@ -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. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md new file mode 100644 index 0000000000..d44d0daaf3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 移除空的实验性包分组 + +Status: implemented + +[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文 + +## 问题 + +包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。 + +原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。 + +## 决策 + +包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。 + +如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。 + +本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。 + +## 考虑过的替代方案 + +**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。 + +**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。 + +**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。 + +**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。 + +**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。 + +## 后果 + +包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。 + +第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。 diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json index 41019f0aa2..5dc925f245 100644 --- a/.github/issue-management/config.json +++ b/.github/issue-management/config.json @@ -3,6 +3,7 @@ "repository": "deepseek-harness", "projectNumber": 1, "projectTitle": "DSH Issue Management", + "lifecycleActor": "dsh-issue-management", "priorityField": "Priority", "allowUnassignedOwner": true, "statuses": [ diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 2125ba2f36..24a82cf15f 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -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) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8a9b0f91e6..c03a7c3513 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -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', () => { diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 7a25b5223d..e324cfefc2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index adc14858d7..e8ed4bc73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000000..6e8f466a1f --- /dev/null +++ b/BENCHMARK.md @@ -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. diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 7555be866b..4284d3d71a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -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() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f05b260a2..bc972314e8 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 646198cea4d30ddc799ef4886af309f376cfa9f2 -config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c +config-catalog.md: cc50a2021cfc378481dc3e830a0973773bae1e02 +config-catalog.zh.md: 5ed4f54a60130927ba710cae1f041da397c2d77a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 646198cea4..cc50a2021c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cda44f7904..5ed4f54a60 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b67ad96c9c..e40160c27b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4e68c0e5e6..33e3f8e672 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 1879e74f3e..2f036ba0ca 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 5778b13667..fb2d7d3d3a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 88d8f833ce3e6c51692db74519279a5354a1759b +persistence-catalog.zh.md: 5ab0fa0c6ccb099ba10b9021625f20486a02d94c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1b94ecc541..88d8f833ce 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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 = { /** 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] ``` -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 ``` -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/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..5ab0fa0c6c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -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 = { /** 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 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..f81e040bda 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: 7deaa9b30b5a6b1e3cbdcc38255b3974b5abf477 +persistence.zh.md: c5afcf67319da408b739d41b2b7ad3eb434ffbad diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..7deaa9b30b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -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 diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..c5afcf6731 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -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 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 7ba2ae289d..177e33013d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df -session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb +session.md: 990b249cde9f02343f2c668aee5d7c000837df56 +session.zh.md: 39e8ff1e8831fd75c8929c93e263622bb5aa6ea4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 0b78e51ebf..990b249cde 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -215,6 +215,17 @@ type SessionEvent = { /** 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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d1e91f684a..39e8ff1e88 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -217,6 +217,17 @@ type SessionEvent = { /** 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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 9a3c08423f..86ecf90b02 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 220ecf493b2d3a84cede34c1cf72dbf0dcd80353 -subagent.zh.md: b0a0df5a778ed758421225e524c87cf8ef213c0b +subagent.md: 42359215b2e21028e13734c2a4cd2488f86a07af +subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 220ecf493b..42359215b2 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -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 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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index b0a0df5a77..0d1d4c42fa 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -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 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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 69b7b7336d..a05bce3269 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -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 }, diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..8173c4aa07 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\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];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\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];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"eb20999e-deb2-4abe-8517-14de8a6ca238"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json new file mode 100644 index 0000000000..640bf92f7f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json @@ -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." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl new file mode 100644 index 0000000000..d1f5447275 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -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"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl new file mode 100644 index 0000000000..f07b01589d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl @@ -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"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl @@ -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"}} diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts new file mode 100644 index 0000000000..ac1b5ae43c --- /dev/null +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -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 { + 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) +}) diff --git a/lefthook.yml b/lefthook.yml index 0ea7f4e537..7ed1fcb886 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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: diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index b4c191a1d0..5f2d8b0585 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735 -README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d +README.md: eb7df95bde10dafd7afcb168d30c9dda90296687 +README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a diff --git a/packages/README.md b/packages/README.md index 19d6e5ba7b..eb7df95bde 100644 --- a/packages/README.md +++ b/packages/README.md @@ -52,7 +52,6 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **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`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 17f58a2922..03cd025102 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -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`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c5ed6a2c98..72d20a2818 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe -README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a +README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f +README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index db477d9403..57569e9c0d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 1ce1e823a7..16629dc70c 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -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` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index dd1f7b9175..5251ca9408 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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, 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, 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) { diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts new file mode 100644 index 0000000000..2c2b5487bb --- /dev/null +++ b/packages/core/session/src/known-event-types.ts @@ -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 = 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', +]) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35dd9d1dab..9e50c18d11 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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 = { /** 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 diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 779a24e748..7fe7ee01c7 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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) }) }) diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md deleted file mode 100644 index ee6bf61598..0000000000 --- a/packages/experimental/AGENTS.md +++ /dev/null @@ -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. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml deleted file mode 100644 index 48cfed37ee..0000000000 --- a/packages/experimental/README.i18n.yaml +++ /dev/null @@ -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 diff --git a/packages/experimental/README.md b/packages/experimental/README.md deleted file mode 100644 index db39af8bb1..0000000000 --- a/packages/experimental/README.md +++ /dev/null @@ -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. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md deleted file mode 100644 index fc5942190a..0000000000 --- a/packages/experimental/README.zh.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/:实验性与内部专用包 - -[English](README.md) | 中文 - -该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。 - -该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。 diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81e150bc20..5c4647769a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -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 /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 85d5253765..4fb2b6017a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -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' }, }, }) diff --git a/packages/scaffold/protocol/README.i18n.yaml b/packages/scaffold/protocol/README.i18n.yaml index f038434410..541155d37c 100644 --- a/packages/scaffold/protocol/README.i18n.yaml +++ b/packages/scaffold/protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/scaffold/protocol/README.md -README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f -README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1 +README.md: 082a890454f900aec51df123669f28814d39d601 +README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430 diff --git a/packages/scaffold/protocol/README.md b/packages/scaffold/protocol/README.md index 88a48957d0..082a890454 100644 --- a/packages/scaffold/protocol/README.md +++ b/packages/scaffold/protocol/README.md @@ -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 diff --git a/packages/scaffold/protocol/README.zh.md b/packages/scaffold/protocol/README.zh.md index 6618d8838a..d9b8460e51 100644 --- a/packages/scaffold/protocol/README.zh.md +++ b/packages/scaffold/protocol/README.zh.md @@ -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`。 ## 模型体验 diff --git a/packages/scaffold/protocol/src/types.ts b/packages/scaffold/protocol/src/types.ts index dc8e11587f..16af2a76ac 100644 --- a/packages/scaffold/protocol/src/types.ts +++ b/packages/scaffold/protocol/src/types.ts @@ -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[] } diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index b3519110b5..a51c88ddb3 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts @@ -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: [], }, }]) }) diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 6cd1c6ed99..256e7bfe42 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -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({ diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 29965759bc..d177f49d29 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -2633,7 +2633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEvent', - declaration: 'export type SessionEvent = {\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 = {\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', diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 809982f94d..2923b9e09b 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -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') } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index a3a4e04164..6411a077cb 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -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, '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) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index cdb239a982..35b3829ed3 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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', () => { diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index ab674469d9..15cf869b69 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -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 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 { 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) { diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7a4de7233..c7402d7d4b 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -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 } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 5f8f910bde..bec3a11dad 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -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) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 15808bb5e4..eed71ad212 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 -README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 +README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82 +README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 391548b1b8..324c00b320 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | 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` | 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` | 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` | 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. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 7213e1ee71..2ef5e9a90f 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -14,9 +14,9 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 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` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index be1edf01c3..eeeb8a5778 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -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 { * 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 { */ list(signal?: AbortSignal): Promise + /** + * 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 { private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { // 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 { 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 { 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 { 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 { 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 { } 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 { } 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)`) } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index dc06517367..97ae8438f1 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -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, diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index c272109f82..8633faa886 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -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() diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 7c82bfe9fb..38329244ba 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -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 | undefined const disposeProcess = (): Promise => (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 { 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. diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index b070f9f5d6..0d7e60cc46 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050 -README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a +README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368 +README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 0bbcfa105e..493bb187d4 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -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. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 8c8551f859..2e1d9b1e60 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -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 阶梯使进程实际退出。 diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index b8a01ea383..194ce3badf 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -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. diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 26043f6a8d..1f518bde8d 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -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()) diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 78106c0d6d..2514a7d420 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md -README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0 -README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14 +README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef +README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 209f1e9526..69def8bf8f 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 8623be4bc1..bf5e7cb5cc 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index ac32fe6b66..b0ab7425a5 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -22,6 +22,7 @@ import { assertSubagentMaxDepth, captureDelegatedPolicyOverrides, childSessionMeta, + finalAssistantOutput, resolveChildAgentOptions, resolveChildDepth, } from '@deepseek-ai/dsh-subagent' @@ -206,9 +207,9 @@ function readResult( structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { const own = child.session.events.slice(boundary) - const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') const lastEnd = findLastMessageTurnEnd(own) - const output: ContentBlock[] = lastMessage?.data.message.content ?? [] + // The seam's canonical selection rule; a partial answer survives cancel and truncation. + const output: ContentBlock[] = finalAssistantOutput(own) ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary // `aborted` end, yielding `disposed` instead. diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index c490bc7d3b..4b9041ebf9 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,4 +1,4 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' @@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -155,6 +156,31 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { + // A tool-only max-tokens step records an empty assistant/message for + // usage. The result retains the preceding assistant output. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + const disposeNoop = ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: 'probe', parameters: {}, + execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) }, + })) + const run = await startInProcessRun(request(parent), {}) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + expect(text(result.output)).toBe('partial one') + await run.dispose() + disposeNoop() + }) + it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) @@ -278,7 +304,12 @@ describe('startInProcessRun', () => { const signalled = await startInProcessRun(request(parent, controller.signal), {}) await new Promise(resolve => setTimeout(resolve, 30)) controller.abort('stop child') - await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' }) + // No step completed a message, so the text streamed before the abort is + // the cancelled run's output. + await expect(signalled.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial' }], + stopReason: 'aborted', + }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index bbcdac2d00..c70bcdf58b 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 42a10adeccb8e299e25ff0a5e0a918ef09b79617 -README.zh.md: 34e2ed6c1ca23df9b3158f3caea10cd19bafa841 +README.md: 28f649ef54bbf88feda24a9ce197c2c366f8349b +README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 42a10adecc..28f649ef54 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 34e2ed6c1c..595c5e5e7f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts new file mode 100644 index 0000000000..6701327cfd --- /dev/null +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -0,0 +1,74 @@ +/** + * Canonical selection of a child's final assistant output. Backend run results + * and `subagent/end.lastAssistantMessage` apply the same rule: select the last + * non-empty assistant message. An empty-content message records usage only + * when the loop appends it after a max-tokens step with no executable blocks, + * so it does not replace earlier output. If no non-empty message exists, + * select the accumulated assistant text. Selection is independent of the + * run's stop reason. + * + * @module @deepseek-ai/dsh-subagent/assistant-output + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Incremental fold of the selection rule, for backends that observe a child's + * output as it streams: session-event backends {@link push} each event, and + * transports without session events (ACP content chunks) {@link pushText} raw + * text into the same streamed fallback. + */ +export class AssistantOutputFold { + private message: ContentBlock[] | undefined + private partial: string[] = [] + + /** + * Fold one session event: a non-empty assistant message becomes the + * candidate final answer, and a `text-delta` chunk extends the streamed + * fallback; every other event contributes nothing. + * @param event - the next observed session event. + */ + push(event: SessionEvent): void { + if (event.type === 'assistant/message') { + const content = event.data.message.content + if (content.length > 0) this.message = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + this.pushText(event.data.chunk.text) + } + } + + /** + * Extend the streamed fallback with text observed outside session events. + * @param text - the next streamed text piece (an empty piece is a no-op). + */ + pushText(text: string): void { + if (text.length > 0) this.partial.push(text) + } + + /** + * Select the final output folded so far. + * @returns the last non-empty assistant message, else the accumulated + * streamed text, or `undefined` when the child produced neither. + */ + collect(): ContentBlock[] | undefined { + if (this.message !== undefined) return this.message + const text = this.partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : undefined + } +} + +/** + * Apply the selection rule to one complete child-owned event suffix. + * @param events - the child-owned events (after any seed or epoch boundary). + * @returns the selected output, or `undefined` when the child produced none. + */ +export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + // TODO: this folds the complete suffix once per run/epoch settlement. If a + // long continuable epoch ever profiles hot here, scan backward with early + // exit for the last non-empty message and fold text deltas only on the + // no-message fallback. + const fold = new AssistantOutputFold() + for (const event of events) fold.push(event) + return fold.collect() +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 916ce431c5..36acb8de28 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' +export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index eff0534d9f..3482a00c0b 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -20,6 +20,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { finalAssistantOutput } from './assistant-output.ts' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' @@ -128,7 +129,8 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - lastAssistantMessage: result.output, + // Omit the field when no output exists, matching continuable epochs. + ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, () => { @@ -173,7 +175,7 @@ export function createActivationObserver( }, capture: (child: Agent): void => { const own = child.session.events.slice(boundary) - const output = lastAssistantOutput(own) + const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), ...output === undefined ? {} : { output }, @@ -220,19 +222,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR } } -/** - * The child's last assistant message content, for one Activation's terminal - * lifecycle edge. Absent when no assistant message reached the log. - * @param events - this epoch's own event suffix. - * @returns its final assistant content, or `undefined` when it produced none. - */ -function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { - const message = events.findLast( - (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', - ) - return message?.data.message.content -} - /** Render any listener-thrown value without letting coercion escape containment. */ function renderThrown(value: unknown): string { try { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index de2fd37115..63a890176e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -64,7 +64,11 @@ export interface SubagentRunEndInfo { readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] - /** The child's final assistant output, absent on infrastructure rejection. */ + /** + * The child's final assistant output, selected by the same rule as + * {@link SubagentResult.output}; absent on infrastructure rejection or when + * the child produced none. + */ readonly lastAssistantMessage?: ContentBlock[] } @@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ export 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 diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts new file mode 100644 index 0000000000..3d510a94e8 --- /dev/null +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts' + +function message(content: ContentBlock[]): SessionEvent { + return { type: 'assistant/message', data: { message: { content } } } as SessionEvent +} + +function textDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent +} + +function reasoningDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent +} + +function toolResult(text: string): SessionEvent { + return { + type: 'tool/result', + data: { + message: { + content: [{ + type: 'tool-result', + toolCallId: 'call-1', + content: [{ type: 'text', text }], + isError: false, + }], + }, + }, + } as SessionEvent +} + +describe('finalAssistantOutput', () => { + it('selects the last non-empty message past a later empty usage-only message', () => { + const events = [ + message([{ type: 'text', text: 'step one' }]), + message([{ type: 'text', text: 'step two' }]), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }]) + }) + + it('prefers a non-empty message over text streamed before and after it', () => { + const events = [ + textDelta('earlier partial'), + message([{ type: 'text', text: 'complete answer' }]), + textDelta('later partial'), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) + }) + + it('treats textless assistant content as a non-empty message', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }] + expect(finalAssistantOutput([ + textDelta('streamed text'), + message(content), + textDelta('later partial'), + ])).toEqual(content) + }) + + it('falls back to text deltas without including reasoning or tool-result content', () => { + const events = [ + reasoningDelta('thinking'), + textDelta('partial '), + toolResult('tool output'), + textDelta('answer'), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('returns undefined when the child produced neither messages nor text', () => { + expect(finalAssistantOutput([])).toBeUndefined() + expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined() + }) +}) + +describe('AssistantOutputFold', () => { + it('folds raw text pieces into the same streamed fallback (ACP chunk transport)', () => { + const fold = new AssistantOutputFold() + fold.pushText('partial ') + fold.pushText('') + fold.pushText('answer') + expect(fold.collect()).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('collects undefined until any output is folded', () => { + expect(new AssistantOutputFold().collect()).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index c91abcf1c0..50bd71b9cc 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -1200,6 +1200,44 @@ describe('continuable review regressions', () => { expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) }) + it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => { + // A tool-only max-tokens step records an empty assistant/message for + // usage. The terminal event retains the previous assistant content, + // including its tool call but not the intervening tool result. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + ctx.tools.register(defineTool({ + name: 'noop', + description: 'does nothing', + parameters: {}, + output: { + schema: { type: 'object', additionalProperties: false, properties: {} }, + render: () => [{ type: 'text', text: 'noop' }], + }, + execute: () => Promise.resolve({}), + })) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('max-tokens') + expect(ends[0]!.lastAssistantMessage).toEqual([ + { type: 'text', text: 'partial one' }, + { type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' }, + ]) + }) + it('reports a resumed epoch that opened no turn without the previous answer', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 5106bc3427..42c778627a 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -15,6 +15,7 @@ import SubagentService, { type SubagentProvider, type SubagentResult, type SubagentRun, + type SubagentRunEndInfo, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -263,6 +264,17 @@ describe('SubagentService', () => { stopReason: 'completed', })) + // The lifecycle event omits lastAssistantMessage when output is empty, + // matching the continuable epoch event. + const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' }) + subagents.registerProvider(silent) + const silentRun = await subagents.start('silent', baseRequest()) + await silentRun.result + await Promise.resolve() + const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent') + expect(silentEnd).toBeDefined() + expect('lastAssistantMessage' in silentEnd!).toBe(false) + const failure = Promise.withResolvers() subagents.registerProvider({ name: 'infra', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e5f0c43f61..8c41c6413a 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a -README.zh.md: 1fd88363b3ade9d57c194580f81295eed139ac50 +README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a +README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6ec313b3b9..ac3ec0563c 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,7 +8,7 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1fd88363b3..d64831d7cf 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,7 +8,7 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 31df8f9e9c..13781bde91 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -134,6 +134,21 @@ function stopReasonError(result: SubagentResult): string | undefined { } } +/** + * Append the child's preserved partial answer to a stop-reason error so a + * truncated or cancelled child's real text still reaches the parent model. + * @param error - the stop-reason headline. + * @param output - the child's selected output (`SubagentResult.output`). + * @returns the headline, extended with the partial text when any exists. + */ +function withPartialText(error: string, output: ContentBlock[]): string { + const text = output + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') + return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` +} + type ForegroundToolResult = { readonly kind: 'foreground' readonly runId: SubagentRun['id'] @@ -149,8 +164,9 @@ async function settleForegroundRun(run: SubagentRun): Promise { const error = stopReasonError(result) if (error !== undefined) { - // The registry converts this throw to isError; partial output is not success. - throw new Error(error) + // The registry converts this throw to isError; partial output is not + // success, but the preserved partial answer still reaches the parent. + throw new Error(withPartialText(error, result.output)) } return { kind: 'foreground', diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 8f289048cb..20cb11db2b 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -154,6 +154,9 @@ describe('dsh-tool-subagent', () => { const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) expect(text(result)).toContain(fragment) + // The failure is not partial success, but the child's preserved partial + // answer still reaches the parent model inside the error result. + expect(text(result)).toContain('scripted subagent reply') }) it('registers under a configurable toolName so multiple providers can coexist', async () => { diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..2e4df208f4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -113,20 +113,42 @@ describe('E2B e2e workflow', () => { }) describe('Issue lifecycle workflow', () => { - it('uses review signals instead of rerunning when a draft becomes ready', () => { + it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') const policy = loadWorkflow('.github/workflows/issue-policy.yml') const policyPullRequest = workflowEvent(policy, 'pull_request') expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') - expect(lifecycleReview.types).toContain('submitted') + expect(lifecycleReview.types).toEqual(['submitted']) + expect(lifecycleJob.if).toBe( + "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", + ) expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('Git hooks', () => { + it('leaves frozen Agent Note sidecars to the archive verifier', () => { + const lefthook = loadWorkflow('lefthook.yml') + + for (const hookName of ['pre-commit', 'pre-merge-commit']) { + const hook = lefthook[hookName] + if (!isRecord(hook) || !Array.isArray(hook.jobs)) { + throw new TypeError(`lefthook must define ${hookName} jobs`) + } + const pairing: unknown = hook.jobs.find( + (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)', + ) + + expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) + } + }) +}) + function loadWorkflow(path: string): Record { const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) @@ -140,6 +162,13 @@ function workflowEvent(workflow: Record, event: string): Record return workflow.on[event] } +function workflowJob(workflow: Record, job: string): Record { + if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) { + throw new TypeError(`workflow must define the ${job} job`) + } + return workflow.jobs[job] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 173d4222cb..debc165eab 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/persistence-catalog.md' +const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts' /** The fenced-block info string for generated declaration blocks (skipped by * doc-typecheck, since their imported types are not standalone-compilable). */ @@ -359,7 +360,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', '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', '', @@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv return lines.join('\n') } -/** CLI entry: default writes the catalog, `--check` fails if the committed copy +/** + * Render the runtime known-vocabulary module: every event type the packages in + * this repo can write, as a generated `ReadonlySet` the read path checks + * unknown-type refusal against (`SessionEvent.ignorable` contract). + */ +export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string { + const names = [...new Set(events.map(e => e.name))].sort() + return [ + '/**', + ' * 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 = new Set([', + ...names.map(name => ` '${name}',`), + '])', + '', + ].join('\n') +} + +/** One generated artifact: repo-relative target and its freshly-rendered content. */ +interface GeneratedArtifact { + readonly out: string + readonly content: string +} + +/** CLI entry: default writes the artifacts, `--check` fails if a committed copy * is stale. Guarded behind an entry-point check so importing this module for - * tests neither regenerates the committed file nor calls process.exit. */ + * tests neither regenerates the committed files nor calls process.exit. */ function main(): void { - const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes()) + const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes()) + const artifacts: GeneratedArtifact[] = [ + { out: OUT, content: render(events, collectEventEnvelopeTypes()) }, + { out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) }, + ] if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". - committed = null - } - if (committed === content) { - console.log(`gen-persistence-catalog: ${OUT} is up to date.`) + const stale = artifacts.filter((artifact) => { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, artifact.out), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + return committed !== artifact.content + }) + if (stale.length === 0) { + console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`) process.exit(0) } - console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`) + console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`) process.exit(1) } - writeFileSync(resolve(root, OUT), content) - console.log(`gen-persistence-catalog: wrote ${OUT}.`) + for (const artifact of artifacts) { + writeFileSync(resolve(root, artifact.out), artifact.content) + console.log(`gen-persistence-catalog: wrote ${artifact.out}.`) + } } // Run only when invoked as a script, not when imported by a test.